const API = window.location.pathname.startsWith('/_dsh_platform/') ? '/_dsh_platform/api/v1' : '/api/v1' const UPDATE_TERMINAL_STATES = new Set(['idle', 'success', 'failed']) const PLUGIN_DRAFT_KEY = 'dsh-platform:system-plugin-draft' const LOG_CLEAR_CUTOFF_KEY = 'dsh-platform:log-clear-cutoff' const LOG_DISPLAY_LIMIT_KEY = 'dsh-platform:log-display-limit' const LOG_DISPLAY_LIMITS = Object.freeze([100, 250, 500, 1_000]) const DEFAULT_LOG_DISPLAY_LIMIT = 500 const LOG_STREAM_LIMIT = 5_000 const TERMINAL_SESSION_KEY = 'dsh-platform:terminal-session' const MANAGEMENT_TRANSITION_TARGET_KEY = 'dsh-platform:management-transition-target' const LANGUAGE_KEY = 'dsh-platform:console-language' const THEME_KEY = 'dsh-platform:console-theme' const COPY = Object.freeze({ zh: Object.freeze({ title: 'DSH 管理中心', consoleLabel: '独立管理控制台', returnDsh: '返回 DSH', switchLanguage: '语言', themeSystem: '跟随系统', themeLight: '亮色', themeDark: '暗色', themeButtonLabel: '当前为{current},点击切换为{next}', authPublicOrigin: '候选管理中心地址', authPublicOriginPlaceholder: 'https://management.example.com', authPublicOriginDetail: '切换前会验证该地址确实连接到当前平台实例。', authLocalPort: '宿主机端口', authLocalPortPlaceholder: '例如:3001', authLocalPortDetail: '填写映射到管理中心入口的宿主机端口。', authUsernameFormat: '用户名支持 1 至 64 个字符,可使用中文、字母、数字、空格和常用符号;不能包含控制字符或双向控制字符。', authPasswordFormat: '密码支持 8 至 1024 个字符,可使用中文、字母、数字、空格和符号;不能包含控制字符或双向控制字符。', authCurrentPasswordRequired: '请输入当前主密码。', authCurrentPasswordIncorrect: '当前主密码不正确。', authManagementPasswordRequired: '请输入新的管理中心密码。', authLocalPortFormat: '请输入 1 至 65535 的宿主机端口。', authTotpTitle: '两步验证(2FA)', authTotpEnabled: '启用两步验证', authTotpPending: '未验证', authTotpSetupTitle: '设置两步验证', authTotpSetupDetail: '使用身份验证器扫描二维码,再输入当前动态验证码。', authTotpDisableTitle: '关闭两步验证', authTotpDisableDetail: '输入当前动态验证码以确认关闭。', authTotpQrAlt: '两步验证二维码', authTotpShowSecret: '显示 2FA 密钥', authTotpHideSecret: '隐藏 2FA 密钥', authTotpCode: '动态验证码', authTotpConfirm: '确认动态码', authTotpCodeInvalid: '动态验证码不正确,请重试。', authTotpCodeRequired: '请输入 6 位动态验证码。', authTotpRetryRequired: '动态验证码连续多次错误,请在 {seconds} 秒后重试。', authTotpRateLimited: '动态验证码尝试次数过多,请在 {seconds} 秒后重试。', authTotpEnrollmentExpired: '二维码已失效,请关闭后重新启用。', authTotpDisableExpired: '关闭确认已失效,请关闭后重试。', authTotpConfirmedDraft: '动态验证码已验证,请保存并应用。', managementSections: 'DSH 管理中心功能', updatesTab: '更新管理', proxyTab: '代理设置', authSettingsTab: '认证设置', authSettingsTitle: '认证设置', authSettingsDetail: '管理本地管理员账户、管理中心密码、浏览器会话和管理中心访问方式。', authUsername: '用户名', authMainPasswordTitle: '管理员账户', authCurrentPassword: '当前主密码', authCurrentPasswordPlaceholder: '请输入当前主密码', authNewPassword: '新密码', authConfirmPassword: '确认密码', authPasswordPlaceholder: '留空表示保持不变', authAdditionalTitle: '管理中心密码', authAdditionalEnabled: '启用管理中心密码', authAdditionalPassword: '新密码', authConfirmAdditionalPassword: '确认密码', authOriginTitle: '管理中心入口', authMode: '访问模式', authModeCompat: '兼容模式', authModeIsolated: '公开独立入口', authModeLocal: '本机独立入口', authModeCompatShort: '兼容模式', authModeIsolatedShort: '独立入口', authOrigin: '候选管理中心地址', authOriginDetail: '切换前会确认候选地址确实连接到当前平台实例,并注销现有管理中心会话。', authAutoDetect: '使用检测地址', authIsolationCompare: '查看模式安全性对比', authRootLocked: '已启用 DSH Root 权限,访问模式已锁定为兼容模式,无法切换。', authComparisonTitle: '管理中心隔离能力对比', authComparisonDetail: '两种模式都要求认证和最终执行点授权;独立入口还会把完整管理会话与 DSH 前端插件隔离。', authComparisonSessions: 'DSH 与管理中心使用独立会话', authComparisonPlugins: '防止同源 DSH 前端插件借用管理会话', authComparisonCapabilities: '最终执行点 capability 鉴权', authComparisonRoot: 'DSH 获得容器 Root 后仍受保护', authComparisonYes: '是', authComparisonNo: '否', authIsolationConfirmTitle: '启用管理中心强隔离模式', authIsolationConfirmDetail: '请先映射容器端口 3081,或将独立地址转发到该端口。平台会在改变当前模式前验证候选入口。', authIsolationConfirm: '我已完成配置,继续', understood: '知道了', authSessionsTitle: '浏览器会话', authSessionSummary: '共 {count} 个登录设备。注销会同时撤销该设备的 DSH 与管理中心会话。', authSessionUnknownDevice: '未知浏览器', authSessionCurrent: '当前设备', authSessionManagement: '管理中心已登录', authSessionIp: 'IP:{value}', authSessionLoginAt: '登录:{value}', authSessionLastSeen: '最后活动:{value}', authSessionRevoke: '注销会话', authSessionsRevoked: '会话已注销。', authSessionExpired: '浏览器会话已失效。', authPasswordMismatch: '两次输入的新密码不一致。', authTransitionFailed: '候选管理中心地址无法通过此浏览器验证。请检查地址、端口映射和 HTTPS 配置。', authTransitionCompatLogin: '兼容模式已启用。请返回你的 DSH 地址打开管理中心。', authTransitionLocalLogin: '本机独立入口已启用。请通过容器宿主机映射的 loopback 地址登录管理中心。', authSaveApply: '保存并应用', authSaved: '认证设置已保存。', authSavedAllSessionsRevoked: '主密码或两步验证已更新,所有登录会话均已注销。正在跳转到登录页面。', authSavedManagementSessionsRevoked: '管理中心密码设置已更新,所有管理中心会话均已注销。正在跳转到登录页面。', maintenanceTab: '运行维护', pluginsTab: '系统插件', skillsTab: '系统技能', userSkillsTab: '用户技能', userPluginsTab: '用户插件', terminalTab: '容器终端', filesTab: '文件管理', proxyMaster: '使用代理', proxyMasterDetail: '关闭时,所有受管流量范围均直连。', proxyTitle: '代理设置', proxyDetail: '为选定的出站流量使用已有的 HTTP 或 SOCKS5 代理。', proxyAdvanced: '高级', proxyUnsaved: '未保存', proxySavedShort: '已保存', proxyProtocol: '协议', proxyHost: '主机', proxyPort: '端口', proxyUsername: '用户名', proxyPassword: '密码', proxyPasswordPlaceholder: '留空表示保留已有密码', proxyRemoteDns: '通过 SOCKS5 解析目标 DNS', proxyRemoteDnsDetail: '避免在容器内解析目标域名。', proxyClearPassword: '清除密码', proxyTransportWarning: '当前页面未使用 HTTPS,代理凭据仅受现有网络边界保护。', proxyComponentUnavailable: '出站代理组件暂不可用', proxyComponentIssueLabel: '查看出站代理组件异常', proxyComponentIssueTitle: '出站代理组件不可用', proxyComponentIssueDetail: 'DSH Docker 的出站代理组件当前不可用。当前配置仅供查看,保存、连接测试和受管代理流量将在组件恢复后可用。这不代表已配置的外部代理是否可连接。', proxyScopes: '流量范围', proxyScopesDetail: '选择哪些受管出站流量使用此代理。', proxyScopeHelp: '范围说明', proxyScopeGuideTitle: '代理范围说明', proxyScopeGuideDetail: '平台如何归类各类联网来源。', proxyScopeUpdates: '更新管理', proxyScopeUpdatesDetail: 'Metadata 检查和远程 Artifact 下载。', proxyScopePlatform: '平台组件', proxyScopePlatformDetail: 'DSH Docker 组件与系统插件的外部请求。', proxyScopeDshCore: 'DSH 核心', proxyScopeDshCoreDetail: '不含模型 Provider API 的 DSH 核心流量。', proxyScopeDshPlugins: 'DSH 插件', proxyScopeDshPluginsDetail: 'DSH 官方插件与用户安装的第三方插件。', proxyScopeAgent: 'Agent 联网操作', proxyScopeAgentDetail: 'Agent 联网工具、命令及其子进程。', proxyScopeTerminal: '容器终端', proxyScopeTerminalDetail: '此管理中心创建的 Shell 会话。', proxyRules: '直连规则', proxyRulesDetail: '列出的目标不会使用外部代理。', proxyDirectRules: '附加直连规则', proxyDirectRulesDetail: '每行一个主机、域后缀、IP 地址或 CIDR;使用 .example.com,不使用 *.example.com。', proxyDirectRulesPlaceholder: '.example.com\n10.0.0.0/8', proxySystemRules: '内置规则', proxySystemRulesTitle: '内置直连规则', proxySystemRulesDetail: '以下平台托管的本地目标始终直连,无需重复填写。', proxyAllProxy: '在已验证支持的客户端中设置 ALL_PROXY', proxyAllProxyDetail: '只向已确认支持 ALL_PROXY 的客户端注入。', proxyProviders: '模型 Provider', proxyProvidersDetail: '可识别的 DSH llm/stream Provider 可分别选择直连或独立代理。', proxyProviderSearch: '搜索已配置的 Provider', proxyNoProviders: '没有符合条件的已配置 Provider。', proxyProviderDirect: '强制直连', proxyProviderIndependent: '可独立配置', proxyProviderShared: '跟随 DSH', proxyProviderInfo: '查看 {name} 的路由说明', proxyProviderReasonLocal: '本地 Provider 始终直连。', proxyProviderReasonShared: '当前客户端无法稳定携带 Provider 身份。开关开启时跟随 DSH 共享流量策略,关闭时直连。', proxyTest: '连接测试', proxyTestDetail: '使用当前表单测试,不保存也不激活配置。', proxyTestStart: '测试连接', proxySave: '保存并应用', proxySaving: '正在保存代理设置', proxySaved: '代理设置已保存并应用', proxyTestRunning: '正在测试代理连接', proxyTestSuccess: '代理连接测试通过', proxyTestFailed: '代理连接测试失败', proxyTestCancelled: '代理连接测试已取消', proxyStageAddress: '解析代理地址', proxyStageConnect: '连接代理服务器', proxyStageHandshake: '验证代理协议与认证', proxyStageDns: '解析目标地址', proxyStageTls: '验证目标 TLS', proxyStageHttp: '请求 GitHub 与 npm', proxyStagePending: '待测试', proxyStageRunning: '测试中', proxyStageSuccess: '通过', proxyStageFailed: '失败', proxyStageCancelled: '已取消', proxyStageSkipped: '无需执行', proxyStageDetailDirect: '当前候选配置使用直连。', proxyStageDetailAddress: '已解析 {count} 个代理地址记录。', proxyStageDetailConnect: '已成功连接代理服务器。', proxyStageDetailHandshake: '已验证 {protocol} 代理协议与认证。', proxyStageDetailDns: '已解析 {count} 个目标地址。', proxyStageDetailTls: '已验证 {count} 个目标的 TLS 连接。', proxyStageDetailHttp: '{results}', proxyTargetGithub: 'GitHub metadata', proxyTargetNpm: 'npm registry', channel: '更新通道', channelDetail: '实验通道仅更新 DSH,平台环境仍使用正式支持版本。', stable: '稳定', experimental: '实验', current: '当前版本', supported: '正式支持版本', upstream: '上游版本', officialNpm: 'npm 官方源', actions: '更新操作', lastChecked: '上次检查', notChecked: '尚未检查', check: '检查更新', checking: '检查中', updateSupported: '更新到最新支持版本', updateUpstream: '更新到最新上游版本', rollback: '回滚到上一版本', returnStable: '立即返回稳定通道', retry: '重试', progress: '更新进度', updateProgress: '更新进度', rollbackProgress: '回滚进度', returnStableProgress: '返回稳定通道', updateToTarget: '更新到 {target}', rollbackToTarget: '回滚到 {target}', returnStableToTarget: '返回稳定通道·{target}', progressPrepare: '准备更新', progressAcquire: '下载与验证', progressBuild: '构建 Runtime', progressActivate: '切换与健康检查', stageLogs: '阶段日志', hideStageLogs: '收起日志 · {count} 条', showStageLogs: '查看日志 · {count} 条', copyStageLogs: '复制当前日志', logsCopied: '日志已复制', viewFullTransactionLog: '查看完整事务日志', noStageLogs: '当前阶段暂无日志', dismissProgress: '关闭', stageCompleted: '阶段已完成。', stageWaiting: '等待前一阶段完成。', stageProgress: '阶段进度 {progress}%', stageItemsCompleted: '已完成 {completed}/{total} 项', expandStage: '展开 · 日志 {count} 条', collapseStage: '收起 · 日志 {count} 条', stageItemCompleted: '已完成:{item}', stageItemActive: '正在执行:{item}', stageItemPending: '待执行:{item}', stageItemFailed: '执行失败:{item}', itemVerifyMetadata: '验证 metadata', itemVerifyKeyring: '验证 keyring', itemVerifyTarget: '验证目标清单', itemDownloadArtifacts: '下载 Artifact', itemVerifyArtifacts: '验证 Artifact 签名、引用、大小和 Hash', itemImportObjects: '导入可信对象库', itemMaterializePristine: '物化 Pristine DSH', itemPrepareEnvironment: '准备 Environment', itemBuildRuntime: '构建 Runtime 并应用完整 Patch Set', itemPreparePlugins: '准备 System Plugin Set', itemSwitchDeployment: '原子切换 Deployment', itemCheckHealth: '检查服务健康状态', itemObserveProbation: '观察候选 Runtime', itemValidateRollback: '验证回滚计划和上一完整 Deployment', itemPauseRuntime: '暂停当前 DSH Runtime', itemSwitchPrevious: '切换上一完整 Deployment', itemVerifySnapshot: '验证数据快照', itemRestoreSnapshot: '恢复数据快照', itemStartRuntime: '启动上一 DSH Runtime', metadataVerified: 'metadata 已验证。', keyringVerified: 'keyring 已验证。', targetManifestVerified: '目标清单已验证。', artifactDownloadCompleted: 'Artifact 下载已完成。', artifactVerificationCompleted: 'Artifact 签名、引用和 Hash 已验证。', runtimeMaterialized: 'Pristine DSH 已物化。', patchSetApplied: '完整 Patch Set 已应用。', systemPluginsPrepared: 'System Plugin Set 已准备。', deploymentSwitched: 'Deployment 已原子切换。', healthChecksPassed: '服务健康检查已通过。', rollbackPrepareCompleted: 'Snapshot 与回滚计划已验证。', rollbackSwitchCompleted: 'Previous Runtime 已激活。', rollbackDataCompleted: '用户数据已恢复。', rollbackVerifyCompleted: '系统健康检查已通过。', metricBytesRead: '已读取 {processed} / {total}', metricBytesCopied: '已复制 {processed} / {total}', metricBytesRestored: '已恢复 {processed} / {total}', metricBytesProcessed: '已处理 {processed} / {total}', metricArtifacts: '已验证 {processed} / {total} 个 Artifact', metricFiles: '已完成 {processed} / {total} 个文件', metricItems: '已完成 {processed} / {total} 项', metricServices: '已就绪 {ready} / {total} 个服务', metricProbationRemaining: '剩余观察 {seconds} 秒', rollbackPrepare: '准备回滚', rollbackSwitch: '切换上一版本', rollbackData: '恢复数据', rollbackVerify: '启动与检查', progressDetailChecking: '正在获取并验证最新的签名更新信息。', progressDetailPlanning: '正在计算需要收敛的完整目标状态。', progressDetailUpstream: '正在查询 npm 官方源中的最新 DSH。', progressDetailDownloading: '正在下载 Artifact,并通过 Stage-0 导入可信对象库。', progressDetailValidating: '正在验证签名、Artifact 引用、大小和内容 Hash。', progressDetailBuilding: '正在从 Pristine DSH、补丁和系统插件构建不可变 Runtime。', progressDetailSnapshot: 'DSH 已暂停,正在为实验更新创建完整数据快照。', progressDetailSwitching: '正在原子切换完整 Deployment,并检查 DSH 是否就绪。', progressDetailProbation: '候选 Runtime 正在持续接受健康检查,观察至 {until}。', rollbackDetailPreparing: '正在验证回滚计划与上一完整 Deployment。', rollbackDetailStopping: '正在暂停 DSH,准备恢复上一完整状态。', rollbackDetailSwitching: '正在切换上一 Runtime、Environment 和系统插件集合。', rollbackDetailData: '正在校验并恢复更新前的数据快照。', rollbackDetailVerifying: '正在启动 DSH 并执行健康检查。', statusIdle: '等待操作', statusChecking: '正在检查更新', statusPlanning: '正在准备更新', statusCheckingUpstream: '正在检查上游版本', statusDownloading: '正在下载', statusValidating: '正在验证', statusBuildingCandidate: '正在构建候选版本', statusSnapshottingData: '正在备份数据', statusSwitching: '正在切换版本', statusProbation: '正在观察运行状态', statusRestoringData: '正在恢复数据', statusRollingBack: '正在回滚', statusSuccess: '操作完成', statusFailed: '操作失败', statusUnknown: '正在处理', outcomeNone: '当前已是最新版本', outcomeFrozen: '等待正式支持版本追上当前版本', outcomeHeld: '此版本已暂停更新', outcomeBlocked: '当前版本组合不可用', outcomeStable: '已切换到稳定版本', outcomeExperimental: '已切换到实验版本', requestError: '请求失败', operationError: '操作失败,请查看容器日志。', holdVersion: '此版本更新失败,已暂停自动重试。', holdCombination: '此版本与正式环境组合不可用,已暂停自动重试。', metadataUnavailable: '正式更新信息暂未发布,请稍后再试。', remoteCheckFailed: '远程检查失败,继续显示上次已验证结果。', remoteCheckFailedNoResult: '远程检查失败,暂无已验证结果。', upstreamCheckFailed: 'DSH 官方版本检查失败,继续显示上次已验证结果。', upstreamCheckFailedNoResult: 'DSH 官方版本检查失败,暂无已验证结果。', aheadOfStable: '当前版本领先正式支持版本,已暂停完整运行组合更新。', experimentalBlocked: '实验 DSH 与正式环境组合不可用。', returnStableTitle: '恢复稳定状态', returnStableWarning: '将恢复以下时间的数据快照,此后产生的数据会丢失:', confirmDataLoss: '我了解并确认丢弃更新后的数据', cancel: '取消', confirm: '确认恢复', automaticChecks: '自动检查', automaticChecksDetail: '仅检查可用版本,不会自动下载或更新。', enabled: '已开启', disabled: '已关闭', checkInterval: '检查频率', updateNotifications: '更新提醒', updateNotificationsDetail: '自动检查发现新版本时,在 DSH 页面弹窗提醒更新。', interval3600: '每 1 小时', interval10800: '每 3 小时', interval21600: '每 6 小时', interval43200: '每 12 小时', interval86400: '每 24 小时', maintenance: 'DSH 生命周期', maintenanceDetail: '启动、停止或重新启动 DSH,容器和管理中心服务保持运行。', startDsh: '启动 DSH', stopDsh: '停止 DSH', restartDsh: '重新启动 DSH', starting: '正在启动 DSH', stopping: '正在停止 DSH', stopped: 'DSH 已停止', restarting: '正在重新启动 DSH', lifecycleRunning: 'DSH 正在运行', lifecycleFailed: 'DSH 操作失败', restartTitle: '确认重新启动 DSH', restartWarning: '当前 DSH 连接会暂时中断,此独立控制台保持可用。', confirmRestart: '确认重启', stopTitle: '确认停止 DSH', stopWarning: 'DSH 将保持停止,直到在此重新启动或容器重启。管理中心保持可用。', confirmStop: '确认停止', runtimeReset: '重置运行时', runtimeResetDetail: '从已验证的 DSH 原始文件和当前平台补丁重新构建运行时,不会删除配置、会话、用户插件或工作区。', cancelRuntimeReset: '取消重置', runtimeResetConfirmTitle: '重置当前运行时', runtimeResetWarning: 'DSH 会短暂停止,当前版本和用户数据保持不变。', confirmRuntimeReset: '重置并重启 DSH', runtimeResetting: '正在重置运行时', runtimeResetBuilding: '正在从已验证文件重建运行时', runtimeResetVerifying: '正在验证重建结果', runtimeResetSwitching: '正在切换运行时', runtimeResetStarting: '正在启动并检查 DSH', runtimeResetRecovering: '重置失败,正在恢复原运行时', runtimeResetProgress: '运行时重置进度', runtimeResetComplete: '运行时已重置并重新启动 DSH', runtimeResetFailed: '运行时重置失败', logs: '实时日志', logsDetail: '查看 DSH 与平台各模块的运行日志。', searchLogs: '搜索日志', logSource: '日志模块', logLevel: '日志级别', logDisplayLimit: '显示条数', logDisplayLimitValue: '最近 {count} 条', allSources: '全部模块', levelAll: '全部级别', levelDebug: '调试', levelInfo: '信息', levelWarning: '警告', levelError: '错误', logsLive: '实时', logsConnecting: '连接中', logsDisconnected: '已断开', autoScroll: '自动滚动', refreshLogs: '刷新日志', exportLogs: '导出日志', clearLogView: '清空显示', logCount: '显示 {shown} / {total} 条', noLogs: '暂无日志', noMatchingLogs: '没有符合筛选条件的日志', systemPlugins: '系统插件', systemPluginsConsoleDetail: '管理当前环境提供的所有系统插件,也可恢复 DSH 中的平台管理集成。', noSystemPlugins: '当前环境没有提供系统插件。', managementIntegration: '平台管理集成,可从此独立页面恢复。', systemPluginRecoveryDetail: 'DSH 启动或运行失败,但你仍可修改系统插件。故障日志可前往“运行维护”查看。', notInstalled: '未安装', pluginEnabled: '已安装并启用', pluginDisabled: '已安装但已禁用', pluginPendingRestart: '待重启', installPlugin: '安装', uninstallPlugin: '卸载', pluginActionWorking: '正在应用插件设置', pluginActionInstall: '正在安装', pluginActionUninstall: '正在卸载', pluginActionEnable: '正在启用', pluginActionDisable: '正在禁用', pluginActionComplete: '插件设置已保存', pluginChangesPending: '有待应用的修改', pluginChangesPendingDetail: '插件修改尚未应用。应用后将重新启动 DSH 并生效。', pendingSystemPluginChanges: '有 {count} 项修改待应用', systemPluginApplyingItem: '{action} @dsh-docker/{id}({current}/{total})', systemPluginRestarting: '插件修改已应用,正在重新启动 DSH', systemSkills: '系统技能', systemSkillsConsoleDetail: '管理当前 Bootstrap 提供的已签名 Agent 操作指引。', noSystemSkills: '当前 Bootstrap 没有提供系统技能。', systemSkillRecoveryDetail: 'DSH 启动或运行失败,但你仍可修改系统技能。故障日志可前往“运行维护”查看。', skillEnabled: '已安装并启用', skillDisabled: '已安装但已禁用', skillActionWorking: '正在应用技能设置', skillActionComplete: '技能设置已应用', userSkills: '用户技能', userSkillsDetail: '管理 DSH 用户目录中的技能,无需重新启动 DSH。', noUserSkills: '没有找到用户技能。', userSkillRecoveryDetail: 'DSH 启动或运行失败,但你仍可修改用户技能。故障日志可前往“运行维护”查看。', userSkillEnabled: '已启用', userSkillDisabled: '已禁用', userSkillDamaged: '元数据损坏', userSkillSource: '来源', userSkillEntry: '目录项', userSkillSourceDsh: 'DSH 用户目录', userSkillSourceAgents: 'Agents 用户目录', deleteUserSkill: '删除', deleteUserSkillTitle: '永久删除用户技能', deleteUserSkillDetail: '将永久删除“{name}”及其文件,此操作无法撤销。', userSkillActionWorking: '正在应用用户技能设置', userSkillActionComplete: '用户技能设置已应用', userSkillActionFailed: '用户技能操作失败', userPlugins: '用户插件', userPluginsDetail: '无需启动 DSH,即可恢复 Web Profile 中由用户安装的插件。', noUserPlugins: 'Web Profile 中没有可管理的用户插件。', dshUnavailable: 'DSH 当前不可用', userPluginVersion: '版本', userPluginSpec: '依赖规格', userPluginSource: '来源', userPluginSourceRegistry: '软件包源', userPluginSourceFile: '本地文件', userPluginSourceGit: 'Git', userPluginSourceUrl: 'URL', userPluginSourceOther: '其他', userPluginEnabled: '已启用', userPluginDisabled: '已禁用', userPluginDamaged: '元数据损坏', userPluginReserved: '与系统插件重名', pendingInstall: '待安装', pendingEnable: '待启用', pendingDisable: '待禁用', pendingUninstall: '待卸载', statusInstalling: '安装中', statusEnabling: '启用中', statusDisabling: '禁用中', statusUninstalling: '卸载中', resourceEnabled: '已启用', resourceDisabled: '已禁用', uninstallUserPlugin: '卸载', cancelUninstall: '取消卸载', noPendingUserPluginChanges: '没有待应用的修改', pendingUserPluginChanges: '有 {count} 项修改待应用', cancelChanges: '取消修改', applyUserPluginChanges: '应用并重新启动 DSH', applyUserPluginChangesOffline: '应用修改', userPluginApplying: '正在应用用户插件修改', userPluginApplyComplete: '用户插件修改已应用', userPluginApplyFailed: '用户插件恢复失败', userPluginApplyDeferred: '用户插件修改已保存;DSH 仍不可用,将在下次成功启动时生效。', userPluginRevisionConflict: '插件状态已发生变化,已重新载入最新状态,请重新选择修改。', userPluginRestartRequired: '需要重新启动 DSH', userPluginRestartRequiredDetail: '用户插件已在终端或其他位置发生变化,重新启动 DSH 后生效。', userPluginMetadataError: '无法读取已安装插件的元数据。', userPluginRecoveryDetail: 'DSH 启动或运行失败,但你仍可修改用户插件。故障日志可前往“运行维护”查看。', userPluginPhaseValidated: '正在验证修改', userPluginPhasePaused: '正在暂停 DSH', userPluginPhaseSnapshotted: '已备份 Web Profile', userPluginPhaseMutating: '正在修改插件', userPluginPhaseCommitted: '修改已保存', userPluginPhaseRestarting: '正在重新启动 DSH', userPluginPhaseRestoring: '正在恢复 Web Profile', terminal: '容器终端', terminalDetail: '使用管理员权限打开交互式容器 Shell;仅重新启动 DSH 时终端会话保持运行。', newTerminal: '新建会话', closeTerminal: '关闭会话', terminalIdle: '没有活动会话', terminalLoading: '正在加载终端组件', terminalLoadFailed: '终端组件加载失败,请重试', terminalStarting: '正在创建会话', terminalConnecting: '正在连接终端', terminalConnected: '终端已连接', terminalReconnecting: '连接中断,正在重连', terminalExited: 'Shell 已退出(状态 {status})', terminalFailed: '终端连接失败', terminalClosed: '终端会话已关闭', terminalPlaceholder: '新建会话后将在此打开交互式 Bash Shell。', terminalScreen: '容器终端', files: '文件管理', filesDetail: '使用管理员权限查看和管理容器文件。', newItem: '新建', upload: '上传', uploadFiles: '上传文件', uploadDirectory: '上传文件夹', dropToUpload: '拖放到此处上传', dropUploadDestination: '文件和文件夹将上传到当前目录。', download: '下载', refresh: '刷新', back: '返回', forward: '前进', parentDirectory: '上级目录', path: '路径', itemType: '新建类型', itemName: '名称', createItem: '创建', createLocation: '创建位置:{path}', invalidFileName: '名称不能为空,不能是 . 或 ..,不能包含 / 或控制字符,且不能超过 255 字节。', filterFiles: '筛选当前目录', searchDirectory: '搜索此目录', showHidden: '显示隐藏文件', managedPathWarning: '此路径由平台管理,修改可能在重启、更新或运行时重建时被覆盖,也可能损坏当前部署。', locations: '快捷位置', selectAll: '全选', fileName: '名称', fileSize: '大小', fileOwner: '用户:用户组', fileModified: '修改时间', fileMode: '权限', calculateSize: '计算', calculatingSize: '计算中', sizeCalculationFailed: '计算失败', emptyDirectory: '此目录为空。', loadMore: '加载更多', itemsPerPage: '每页数量', itemsPerPageSuffix: '条/页', previousPage: '上一页', nextPage: '下一页', totalItems: '共 {total} 条', goToPage: '前往', pageUnit: '页', searchSystemPlugins: '搜索系统插件', searchSystemSkills: '搜索系统技能', searchUserPlugins: '搜索用户插件', searchUserSkills: '搜索用户技能', noMatchingResources: '没有符合搜索条件的项目。', noFilesSelected: '未选择文件', filesSelected: '已选择 {count} 项', copy: '复制', cut: '剪切', paste: '粘贴', compress: '压缩', extract: '解压', rename: '重命名', deletePermanently: '永久删除', compressItems: '压缩所选项目', extractArchive: '解压归档', archiveFormat: '归档格式', archiveName: '归档名称', invalidArchiveName: '请输入有效的归档名称。', unsupportedArchive: '请选择 ZIP、7z 或 tar.gz 文件。', editPermissions: '编辑权限', permissions: '权限', permissionRead: '读取', permissionWrite: '写入', permissionExecute: '可执行', permissionOwner: '所有者', permissionGroup: '用户组', permissionOthers: '其他用户', fileUser: '用户', fileGroup: '用户组', recursiveAttributes: '同时修改子项属性', applyPermissions: '应用权限', attributesInvalid: '请填写有效的用户、用户组和 3 或 4 位八进制权限。', attributesOperation: '修改文件属性', newFile: '新建文件', newDirectory: '新建目录', enterName: '请输入名称', searchRunning: '正在搜索目录', taskRunning: '正在执行 {operation}', uploadProgress: '正在上传 {current} / {total}', fileOperations: '文件任务', queuedOperation: '排队第 {position} 位', processingOperation: '正在处理', cancelOperation: '取消操作', operationComplete: '文件操作已完成', operationFailed: '文件操作失败', attributesUnsupported: '当前挂载不支持修改 Unix 用户、用户组或权限。请改用支持 Unix metadata 的 Linux/WSL 路径或 named volume。', confirmDeleteFiles: '永久删除选中的 {count} 项?此操作无法撤销。', operationSucceeded: '已完成', operationCancelled: '已取消', operationFailedState: '失败', editFile: '编辑文件', fileContent: '文件内容', close: '关闭', reload: '重新加载', saveAs: '另存为', save: '保存', renameItem: '重命名项目', renameItemDetail: '为所选项目输入新名称。', saveAsTitle: '另存为', saveAsDetail: '输入要保存到的容器绝对路径。', discardChangesTitle: '放弃未保存修改', unsavedFile: '有未保存的文件修改,确定丢弃吗?', discardChanges: '放弃修改', deleteFilesTitle: '永久删除', confirmDelete: '确认删除', fileEditorUnsaved: '有未保存的修改', fileEditorSaved: '所有修改均已保存', fileEditorSaving: '正在保存', fileSaved: '文件已保存', fileRevisionChanged: '文件已被其他程序修改,请重新加载或另存为。', clipboardCopy: '已复制 {count} 项,进入目标目录后点击粘贴。', clipboardMove: '已剪切 {count} 项,进入目标目录后点击粘贴。', fileConflictTitle: '目标已存在', fileConflictDetail: '请选择处理方式:', conflictOverwrite: '覆盖', conflictOverwriteDetail: '替换已有项目。', conflictRename: '自动重命名', conflictRenameDetail: '使用新名称保留两个项目。', conflictSkip: '跳过', conflictSkipDetail: '保留已有项目,不执行本项操作。', conflictApplyAll: '应用到之后的所有冲突', confirmChoice: '确认', operationCompleteWithSkipped: '文件操作已完成,已跳过 {count} 个冲突项。', online: '已连接', connecting: '正在重连', offline: '连接中断', }), en: Object.freeze({ title: 'DSH Management Console', consoleLabel: 'Standalone console', returnDsh: 'Return to DSH', switchLanguage: 'Language', themeSystem: 'System', themeLight: 'Light', themeDark: 'Dark', themeButtonLabel: '{current}; switch to {next}', authPublicOrigin: 'Candidate Management origin', authPublicOriginPlaceholder: 'https://management.example.com', authPublicOriginDetail: 'The platform verifies that this origin reaches the current instance before switching.', authLocalPort: 'Host port', authLocalPortPlaceholder: 'For example: 3001', authLocalPortDetail: 'Enter the host port mapped to the Management entry.', authUsernameFormat: 'Use 1 to 64 characters. Unicode letters, numbers, spaces, and common symbols are supported; control and bidirectional-control characters are not.', authPasswordFormat: 'Use 8 to 1024 characters. Unicode letters, numbers, spaces, and symbols are supported; control and bidirectional-control characters are not.', authCurrentPasswordRequired: 'Enter the current main password.', authCurrentPasswordIncorrect: 'The current main password is incorrect.', authManagementPasswordRequired: 'Enter a new Management console password.', authLocalPortFormat: 'Enter a host port from 1 to 65535.', authTotpTitle: 'Two-factor authentication (2FA)', authTotpEnabled: 'Enable two-factor authentication', authTotpPending: 'Unverified', authTotpSetupTitle: 'Set up two-factor authentication', authTotpSetupDetail: 'Scan the QR code with an authenticator app, then enter its current code.', authTotpDisableTitle: 'Disable two-factor authentication', authTotpDisableDetail: 'Enter the current authentication code to confirm.', authTotpQrAlt: 'Two-factor authentication QR code', authTotpShowSecret: 'Show 2FA secret', authTotpHideSecret: 'Hide 2FA secret', authTotpCode: 'Authentication code', authTotpConfirm: 'Confirm code', authTotpCodeInvalid: 'The authentication code is incorrect. Try again.', authTotpCodeRequired: 'Enter the 6-digit authentication code.', authTotpRetryRequired: 'Too many incorrect codes. Try again in {seconds} seconds.', authTotpRateLimited: 'Too many authentication-code attempts. Try again in {seconds} seconds.', authTotpEnrollmentExpired: 'This QR code has expired. Close this dialog and enable 2FA again.', authTotpDisableExpired: 'The disable confirmation has expired. Close this dialog and try again.', authTotpConfirmedDraft: 'Authentication code verified. Save and apply the change.', managementSections: 'Platform management sections', updatesTab: 'Updates', proxyTab: 'Proxy', authSettingsTab: 'Authentication', authSettingsTitle: 'Authentication settings', authSettingsDetail: 'Manage the local administrator account, Management console password, browser sessions, and Management access mode.', authUsername: 'Username', authMainPasswordTitle: 'Administrator account', authCurrentPassword: 'Current main password', authCurrentPasswordPlaceholder: 'Enter the current main password', authNewPassword: 'New password', authConfirmPassword: 'Confirm password', authPasswordPlaceholder: 'Leave blank to keep the current value', authAdditionalTitle: 'Management console password', authAdditionalEnabled: 'Enable the Management console password', authAdditionalPassword: 'New password', authConfirmAdditionalPassword: 'Confirm password', authOriginTitle: 'Management origin', authMode: 'Access mode', authModeCompat: 'Compatibility mode', authModeIsolated: 'Public separate origin', authModeLocal: 'Local separate origin', authModeCompatShort: 'Compatibility', authModeIsolatedShort: 'Separate origin', authOrigin: 'Candidate Management origin', authOriginDetail: 'The candidate origin is verified against this platform instance before changing modes and signing out existing Management sessions.', authAutoDetect: 'Use detected address', authIsolationCompare: 'Compare security', authRootLocked: 'DSH Root capability is enabled. Access mode is locked to compatibility mode and cannot be changed.', authComparisonTitle: 'Management isolation comparison', authComparisonDetail: 'Both modes require authentication and final-execution authorization. A separate origin additionally isolates the full Management session from DSH client plugins.', authComparisonSessions: 'Separate DSH and Management sessions', authComparisonPlugins: 'Protect from same-origin DSH client plugins', authComparisonCapabilities: 'Final-execution capability authorization', authComparisonRoot: 'Protected after DSH obtains container Root', authComparisonYes: 'Yes', authComparisonNo: 'No', authIsolationConfirmTitle: 'Enable separate Management origin?', authIsolationConfirmDetail: 'First publish container port 3081 or route a separate address to it. The platform verifies the candidate before changing the current mode.', authIsolationConfirm: 'I have configured the entry', understood: 'Understood', authSessionsTitle: 'Browser sessions', authSessionSummary: '{count} signed-in device(s). Signing one out revokes its DSH and linked Management session together.', authSessionUnknownDevice: 'Unknown browser', authSessionCurrent: 'Current device', authSessionManagement: 'Management signed in', authSessionIp: 'IP: {value}', authSessionLoginAt: 'Signed in: {value}', authSessionLastSeen: 'Last active: {value}', authSessionRevoke: 'Sign out session', authSessionsRevoked: 'Session signed out.', authSessionExpired: 'The browser session has expired.', authPasswordMismatch: 'The new password confirmation does not match.', authTransitionFailed: 'The candidate Management address could not be verified by this browser. Check the address, port mapping, and HTTPS configuration.', authTransitionCompatLogin: 'Compatibility mode is enabled. Return to your DSH address to open Management.', authTransitionLocalLogin: 'Local separate origin is enabled. Sign in through the loopback address mapped on the container host.', authSaveApply: 'Save and apply', authSaved: 'Authentication settings saved.', authSavedAllSessionsRevoked: 'The main password or two-factor authentication was updated and all sessions were signed out. Redirecting to sign in.', authSavedManagementSessionsRevoked: 'The Management console password settings were updated and all Management sessions were signed out. Redirecting to sign in.', maintenanceTab: 'Maintenance', pluginsTab: 'System plugins', skillsTab: 'System skills', userSkillsTab: 'User skills', userPluginsTab: 'User plugins', terminalTab: 'Container terminal', filesTab: 'Files', proxyMaster: 'Use proxy', proxyMasterDetail: 'When off, all managed traffic scopes connect directly.', proxyTitle: 'Proxy settings', proxyDetail: 'Use an existing HTTP or SOCKS5 proxy for selected outbound traffic.', proxyAdvanced: 'Advanced', proxyUnsaved: 'Unsaved', proxySavedShort: 'Saved', proxyProtocol: 'Protocol', proxyHost: 'Host', proxyPort: 'Port', proxyUsername: 'Username', proxyPassword: 'Password', proxyPasswordPlaceholder: 'Leave blank to keep the saved password', proxyRemoteDns: 'Resolve target DNS through SOCKS5', proxyRemoteDnsDetail: 'Avoids resolving target names inside the container.', proxyClearPassword: 'Clear password', proxyTransportWarning: 'This page is not using HTTPS. Proxy credentials are protected only by the current network boundary.', proxyComponentUnavailable: 'Outbound Proxy is unavailable', proxyComponentIssueLabel: 'View outbound proxy component error', proxyComponentIssueTitle: 'Outbound proxy component unavailable', proxyComponentIssueDetail: 'The DSH Docker outbound proxy component is unavailable. The current configuration is read-only until the component recovers; saving, connection tests, and managed proxy traffic are unavailable. This does not indicate whether the configured external proxy is reachable.', proxyScopes: 'Traffic scopes', proxyScopesDetail: 'Select which managed outbound traffic uses this proxy.', proxyScopeHelp: 'Scope guide', proxyScopeGuideTitle: 'Proxy scope guide', proxyScopeGuideDetail: 'How managed network sources are classified.', proxyScopeUpdates: 'Updates', proxyScopeUpdatesDetail: 'Metadata checks and remote Artifact downloads.', proxyScopePlatform: 'Platform components', proxyScopePlatformDetail: 'External requests from DSH Docker components and System Plugins.', proxyScopeDshCore: 'DSH core', proxyScopeDshCoreDetail: 'DSH core traffic excluding model Provider APIs.', proxyScopeDshPlugins: 'DSH plugins', proxyScopeDshPluginsDetail: 'Official and user-installed DSH plugins.', proxyScopeAgent: 'Agent network operations', proxyScopeAgentDetail: 'Agent network tools, commands, and child processes.', proxyScopeTerminal: 'Container terminal', proxyScopeTerminalDetail: 'Shell sessions created by this Management Console.', proxyRules: 'Direct rules', proxyRulesDetail: 'Listed destinations bypass the external proxy.', proxyDirectRules: 'Additional direct rules', proxyDirectRulesDetail: 'One host, domain suffix, IP address, or CIDR per line. Use .example.com, not *.example.com.', proxyDirectRulesPlaceholder: '.example.com\n10.0.0.0/8', proxySystemRules: 'Built-in rules', proxySystemRulesTitle: 'Built-in direct rules', proxySystemRulesDetail: 'These platform-managed local destinations are always direct and do not need to be entered again.', proxyAllProxy: 'Set ALL_PROXY where support is verified', proxyAllProxyDetail: 'Injected only into clients with verified ALL_PROXY support.', proxyProviders: 'Model Providers', proxyProvidersDetail: 'Identifiable DSH llm/stream Providers can independently select direct access or a dedicated proxy route.', proxyProviderSearch: 'Search configured Providers', proxyNoProviders: 'No configured Providers match the current search.', proxyProviderDirect: 'Forced direct', proxyProviderIndependent: 'Independent routing', proxyProviderShared: 'Follow DSH', proxyProviderInfo: 'View routing information for {name}', proxyProviderReasonLocal: 'Local Providers are always direct.', proxyProviderReasonShared: 'The client cannot carry a stable Provider identity. When enabled it follows shared DSH traffic; when disabled it connects directly.', proxyTest: 'Connection test', proxyTestDetail: 'Tests the current form without saving or activating it.', proxyTestStart: 'Test connection', proxySave: 'Save and apply', proxySaving: 'Saving proxy settings', proxySaved: 'Proxy settings saved and applied', proxyTestRunning: 'Testing proxy connection', proxyTestSuccess: 'Proxy connection test passed', proxyTestFailed: 'Proxy connection test failed', proxyTestCancelled: 'Proxy connection test cancelled', proxyStageAddress: 'Resolve proxy address', proxyStageConnect: 'Connect to proxy server', proxyStageHandshake: 'Verify proxy protocol and authentication', proxyStageDns: 'Resolve target addresses', proxyStageTls: 'Verify target TLS', proxyStageHttp: 'Request GitHub and npm', proxyStagePending: 'Pending test', proxyStageRunning: 'Testing', proxyStageSuccess: 'Passed', proxyStageFailed: 'Failed', proxyStageCancelled: 'Cancelled', proxyStageSkipped: 'Not required', proxyStageDetailDirect: 'The candidate configuration connects directly.', proxyStageDetailAddress: 'Resolved {count} proxy address record(s).', proxyStageDetailConnect: 'Connected to the proxy server.', proxyStageDetailHandshake: 'Verified the {protocol} proxy protocol and authentication.', proxyStageDetailDns: 'Resolved {count} target address(es).', proxyStageDetailTls: 'Verified TLS for {count} target(s).', proxyStageDetailHttp: '{results}', proxyTargetGithub: 'GitHub metadata', proxyTargetNpm: 'npm registry', channel: 'Update channel', channelDetail: 'Experimental updates DSH only; the platform Environment remains on the supported release.', stable: 'Stable', experimental: 'Experimental', current: 'Current', supported: 'Supported', upstream: 'Upstream', officialNpm: 'Official npm', actions: 'Update actions', lastChecked: 'Last checked', notChecked: 'Not checked yet', check: 'Check for updates', checking: 'Checking', updateSupported: 'Update to latest supported', updateUpstream: 'Update to latest upstream', rollback: 'Roll back previous', returnStable: 'Return to Stable now', retry: 'Retry', progress: 'Update progress', updateProgress: 'Update progress', rollbackProgress: 'Rollback progress', returnStableProgress: 'Return to Stable', updateToTarget: 'Update to {target}', rollbackToTarget: 'Roll back to {target}', returnStableToTarget: 'Return to Stable · {target}', progressPrepare: 'Prepare update', progressAcquire: 'Download and verify', progressBuild: 'Build Runtime', progressActivate: 'Switch and health check', stageLogs: 'Stage logs', hideStageLogs: 'Hide logs · {count}', showStageLogs: 'View logs · {count}', copyStageLogs: 'Copy current logs', logsCopied: 'Logs copied', viewFullTransactionLog: 'View full transaction log', noStageLogs: 'No logs for this phase yet', dismissProgress: 'Close', stageCompleted: 'Stage completed.', stageWaiting: 'Waiting for the previous stage.', stageProgress: 'Stage progress {progress}%', stageItemsCompleted: '{completed}/{total} items completed', expandStage: 'Expand · {count} log entries', collapseStage: 'Collapse · {count} log entries', stageItemCompleted: 'Completed: {item}', stageItemActive: 'In progress: {item}', stageItemPending: 'Pending: {item}', stageItemFailed: 'Failed: {item}', itemVerifyMetadata: 'Verify metadata', itemVerifyKeyring: 'Verify keyring', itemVerifyTarget: 'Verify target manifest', itemDownloadArtifacts: 'Download Artifacts', itemVerifyArtifacts: 'Verify Artifact signatures, references, sizes, and hashes', itemImportObjects: 'Import trusted objects', itemMaterializePristine: 'Materialize Pristine DSH', itemPrepareEnvironment: 'Prepare Environment', itemBuildRuntime: 'Build Runtime and apply the complete Patch Set', itemPreparePlugins: 'Prepare System Plugin Set', itemSwitchDeployment: 'Switch Deployment atomically', itemCheckHealth: 'Check service health', itemObserveProbation: 'Observe candidate Runtime', itemValidateRollback: 'Validate rollback plan and previous complete Deployment', itemPauseRuntime: 'Pause current DSH Runtime', itemSwitchPrevious: 'Switch previous complete Deployment', itemVerifySnapshot: 'Verify data snapshot', itemRestoreSnapshot: 'Restore data snapshot', itemStartRuntime: 'Start previous DSH Runtime', metadataVerified: 'Metadata verified.', keyringVerified: 'Keyring verified.', targetManifestVerified: 'Target manifest verified.', artifactDownloadCompleted: 'Artifact download completed.', artifactVerificationCompleted: 'Artifact signatures, references, and hashes verified.', runtimeMaterialized: 'Pristine DSH materialized.', patchSetApplied: 'Complete Patch Set applied.', systemPluginsPrepared: 'System Plugin Set prepared.', deploymentSwitched: 'Deployment switched atomically.', healthChecksPassed: 'Service health checks passed.', rollbackPrepareCompleted: 'Snapshot and rollback plan verified.', rollbackSwitchCompleted: 'Previous Runtime activated.', rollbackDataCompleted: 'User data restored.', rollbackVerifyCompleted: 'System health checks passed.', metricBytesRead: 'Read {processed} / {total}', metricBytesCopied: 'Copied {processed} / {total}', metricBytesRestored: 'Restored {processed} / {total}', metricBytesProcessed: 'Processed {processed} / {total}', metricArtifacts: 'Verified {processed} / {total} Artifacts', metricFiles: 'Completed {processed} / {total} files', metricItems: 'Completed {processed} / {total} items', metricServices: '{ready} / {total} services ready', metricProbationRemaining: '{seconds} seconds of observation remaining', rollbackPrepare: 'Prepare rollback', rollbackSwitch: 'Switch previous version', rollbackData: 'Restore data', rollbackVerify: 'Start and check', progressDetailChecking: 'Fetching and verifying the latest signed update metadata.', progressDetailPlanning: 'Calculating the complete target state to reconcile.', progressDetailUpstream: 'Checking the official npm registry for the latest DSH.', progressDetailDownloading: 'Downloading Artifacts and importing them through Stage-0 into the trusted object store.', progressDetailValidating: 'Verifying signatures, Artifact references, sizes, and content hashes.', progressDetailBuilding: 'Building an immutable Runtime from Pristine DSH, patches, and System Plugins.', progressDetailSnapshot: 'DSH is paused while a complete data snapshot is created for the Experimental update.', progressDetailSwitching: 'Atomically switching the complete Deployment and checking DSH readiness.', progressDetailProbation: 'The candidate Runtime remains under health observation until {until}.', rollbackDetailPreparing: 'Validating the rollback plan and previous complete Deployment.', rollbackDetailStopping: 'Pausing DSH before restoring the previous complete state.', rollbackDetailSwitching: 'Switching the previous Runtime, Environment, and System Plugin set.', rollbackDetailData: 'Verifying and restoring the pre-update data snapshot.', rollbackDetailVerifying: 'Starting DSH and running health checks.', statusIdle: 'Ready', statusChecking: 'Checking for updates', statusPlanning: 'Preparing update', statusCheckingUpstream: 'Checking upstream', statusDownloading: 'Downloading', statusValidating: 'Verifying', statusBuildingCandidate: 'Building candidate', statusSnapshottingData: 'Backing up data', statusSwitching: 'Switching version', statusProbation: 'Observing runtime health', statusRestoringData: 'Restoring data', statusRollingBack: 'Rolling back', statusSuccess: 'Completed', statusFailed: 'Failed', statusUnknown: 'Working', outcomeNone: 'Already up to date', outcomeFrozen: 'Waiting for the supported release to catch up', outcomeHeld: 'This version is on hold', outcomeBlocked: 'This version combination is unavailable', outcomeStable: 'Switched to the Stable release', outcomeExperimental: 'Switched to the Experimental release', requestError: 'Request failed', operationError: 'The operation failed. Check the container logs.', holdVersion: 'This version failed and automatic retries are on hold.', holdCombination: 'This version is incompatible with the production Environment and automatic retries are on hold.', metadataUnavailable: 'Signed update metadata has not been published yet. Try again later.', remoteCheckFailed: 'Remote check failed. Showing the last verified result.', remoteCheckFailedNoResult: 'Remote check failed. No verified result is available yet.', upstreamCheckFailed: 'The official DSH version check failed. Showing the last verified result.', upstreamCheckFailedNoResult: 'The official DSH version check failed. No verified result is available yet.', aheadOfStable: 'The current version is ahead of Latest Supported; the complete deployment is frozen.', experimentalBlocked: 'The Experimental DSH and production Environment combination is unavailable.', returnStableTitle: 'Restore Stable state', returnStableWarning: 'The following data snapshot will be restored and newer data will be lost:', confirmDataLoss: 'I understand and confirm the loss of newer data', cancel: 'Cancel', confirm: 'Restore', automaticChecks: 'Automatic checks', automaticChecksDetail: 'Checks for available versions without downloading or updating.', enabled: 'On', disabled: 'Off', checkInterval: 'Check frequency', updateNotifications: 'Update notifications', updateNotificationsDetail: 'Show an update notification popup on DSH pages when an automatic check finds a new version.', interval3600: 'Every hour', interval10800: 'Every 3 hours', interval21600: 'Every 6 hours', interval43200: 'Every 12 hours', interval86400: 'Every 24 hours', maintenance: 'DSH lifecycle', maintenanceDetail: 'Start, stop, or restart DSH while the container and management console services remain running.', startDsh: 'Start DSH', stopDsh: 'Stop DSH', restartDsh: 'Restart DSH', starting: 'Starting DSH', stopping: 'Stopping DSH', stopped: 'DSH stopped', restarting: 'Restarting DSH', lifecycleRunning: 'DSH is running', lifecycleFailed: 'DSH operation failed', restartTitle: 'Restart DSH?', restartWarning: 'The current DSH connection will be interrupted briefly. This standalone console remains available.', confirmRestart: 'Restart', stopTitle: 'Stop DSH?', stopWarning: 'DSH remains stopped until it is started here or the container restarts. The management console remains available.', confirmStop: 'Stop DSH', runtimeReset: 'Reset runtime', runtimeResetDetail: 'Rebuild the runtime from verified DSH files and current platform patches without deleting configuration, sessions, user plugins, or workspaces.', cancelRuntimeReset: 'Cancel reset', runtimeResetConfirmTitle: 'Reset the current runtime', runtimeResetWarning: 'DSH stops briefly. The current version and user data remain unchanged.', confirmRuntimeReset: 'Reset and restart DSH', runtimeResetting: 'Resetting runtime', runtimeResetBuilding: 'Rebuilding runtime from verified files', runtimeResetVerifying: 'Verifying rebuilt runtime', runtimeResetSwitching: 'Switching runtime', runtimeResetStarting: 'Starting and checking DSH', runtimeResetRecovering: 'Reset failed; restoring the previous runtime', runtimeResetProgress: 'Runtime reset progress', runtimeResetComplete: 'Runtime reset and DSH restarted', runtimeResetFailed: 'Runtime reset failed', logs: 'Live logs', logsDetail: 'View runtime logs from DSH and platform modules.', searchLogs: 'Search logs', logSource: 'Log module', logLevel: 'Log level', logDisplayLimit: 'Entries shown', logDisplayLimitValue: 'Latest {count}', allSources: 'All modules', levelAll: 'All levels', levelDebug: 'Debug', levelInfo: 'Info', levelWarning: 'Warning', levelError: 'Error', logsLive: 'Live', logsConnecting: 'Connecting', logsDisconnected: 'Disconnected', autoScroll: 'Auto-scroll', refreshLogs: 'Refresh logs', exportLogs: 'Export logs', clearLogView: 'Clear view', logCount: 'Showing {shown} / {total}', noLogs: 'No logs yet', noMatchingLogs: 'No logs match these filters', systemPlugins: 'System plugins', systemPluginsConsoleDetail: 'Manage every bundled System Plugin, including recovery of the Platform Management integration in DSH.', noSystemPlugins: 'The current Environment provides no System Plugins.', managementIntegration: 'Platform Management integration, recoverable from this standalone page.', systemPluginRecoveryDetail: 'DSH failed to start or stopped unexpectedly, but you can still change System Plugins. Check Maintenance for failure logs.', notInstalled: 'Not installed', pluginEnabled: 'Installed and enabled', pluginDisabled: 'Installed but disabled', pluginPendingRestart: 'Restart required', installPlugin: 'Install', uninstallPlugin: 'Uninstall', pluginActionWorking: 'Applying plugin settings', pluginActionInstall: 'Installing', pluginActionUninstall: 'Uninstalling', pluginActionEnable: 'Enabling', pluginActionDisable: 'Disabling', pluginActionComplete: 'Plugin settings saved', pluginChangesPending: 'Changes pending', pluginChangesPendingDetail: 'Plugin changes have not been applied. Apply them to restart DSH and make them effective.', pendingSystemPluginChanges: '{count} pending changes', systemPluginApplyingItem: '{action} @dsh-docker/{id} ({current}/{total})', systemPluginRestarting: 'Plugin changes applied; restarting DSH', systemSkills: 'System skills', systemSkillsConsoleDetail: 'Manage signed Agent guidance supplied by the current Bootstrap.', noSystemSkills: 'The current Bootstrap provides no System Skills.', systemSkillRecoveryDetail: 'DSH failed to start or stopped unexpectedly, but you can still change System Skills. Check Maintenance for failure logs.', skillEnabled: 'Installed and enabled', skillDisabled: 'Installed but disabled', skillActionWorking: 'Applying skill settings', skillActionComplete: 'Skill settings applied', userSkills: 'User skills', userSkillsDetail: 'Manage skills in the DSH user roots without restarting DSH.', noUserSkills: 'No user skills were found.', userSkillRecoveryDetail: 'DSH failed to start or stopped unexpectedly, but you can still change User Skills. Check Maintenance for failure logs.', userSkillEnabled: 'Enabled', userSkillDisabled: 'Disabled', userSkillDamaged: 'Damaged metadata', userSkillSource: 'Source', userSkillEntry: 'Entry', userSkillSourceDsh: 'DSH user directory', userSkillSourceAgents: 'Agents user directory', deleteUserSkill: 'Delete', deleteUserSkillTitle: 'Permanently delete user skill', deleteUserSkillDetail: 'Permanently delete “{name}” and its files? This cannot be undone.', userSkillActionWorking: 'Applying User Skill settings', userSkillActionComplete: 'User Skill settings applied', userSkillActionFailed: 'User Skill operation failed', userPlugins: 'User plugins', userPluginsDetail: 'Recover user-installed Web Profile plugins without starting DSH.', noUserPlugins: 'No managed user plugins were found in the Web Profile.', dshUnavailable: 'DSH is unavailable', userPluginVersion: 'Version', userPluginSpec: 'Dependency spec', userPluginSource: 'Source', userPluginSourceRegistry: 'Registry', userPluginSourceFile: 'Local file', userPluginSourceGit: 'Git', userPluginSourceUrl: 'URL', userPluginSourceOther: 'Other', userPluginEnabled: 'Enabled', userPluginDisabled: 'Disabled', userPluginDamaged: 'Damaged metadata', userPluginReserved: 'Conflicts with a System Plugin', pendingInstall: 'Pending install', pendingEnable: 'Pending enable', pendingDisable: 'Pending disable', pendingUninstall: 'Pending uninstall', statusInstalling: 'Installing', statusEnabling: 'Enabling', statusDisabling: 'Disabling', statusUninstalling: 'Uninstalling', resourceEnabled: 'Enabled', resourceDisabled: 'Disabled', uninstallUserPlugin: 'Uninstall', cancelUninstall: 'Cancel uninstall', noPendingUserPluginChanges: 'No pending changes', pendingUserPluginChanges: '{count} changes pending', cancelChanges: 'Cancel changes', applyUserPluginChanges: 'Apply and restart DSH', applyUserPluginChangesOffline: 'Apply changes', userPluginApplying: 'Applying user plugin changes', userPluginApplyComplete: 'User plugin changes applied', userPluginApplyFailed: 'User plugin recovery failed', userPluginApplyDeferred: 'User plugin changes were saved. DSH remains unavailable and will use them on its next successful start.', userPluginRevisionConflict: 'Plugin state changed. The latest inventory has been loaded; select your changes again.', userPluginRestartRequired: 'Restart DSH required', userPluginRestartRequiredDetail: 'User plugins changed in the terminal or elsewhere and take effect after DSH restarts.', userPluginMetadataError: 'Installed plugin metadata could not be read.', userPluginRecoveryDetail: 'DSH failed to start or stopped unexpectedly, but you can still change User Plugins. Check Maintenance for failure logs.', userPluginPhaseValidated: 'Validating changes', userPluginPhasePaused: 'Pausing DSH', userPluginPhaseSnapshotted: 'Web Profile backed up', userPluginPhaseMutating: 'Changing plugins', userPluginPhaseCommitted: 'Changes saved', userPluginPhaseRestarting: 'Restarting DSH', userPluginPhaseRestoring: 'Restoring Web Profile', terminal: 'Container terminal', terminalDetail: 'Open an interactive container shell with administrator privileges. The session remains running when only DSH restarts.', newTerminal: 'New session', closeTerminal: 'Close session', terminalIdle: 'No active session', terminalLoading: 'Loading terminal components', terminalLoadFailed: 'Terminal components failed to load. Try again.', terminalStarting: 'Creating session', terminalConnecting: 'Connecting terminal', terminalConnected: 'Terminal connected', terminalReconnecting: 'Connection lost, reconnecting', terminalExited: 'Shell exited ({status})', terminalFailed: 'Terminal connection failed', terminalClosed: 'Terminal session closed', terminalPlaceholder: 'Start a session to open an interactive Bash shell.', terminalScreen: 'Container terminal', files: 'File management', filesDetail: 'View and manage container files with administrator privileges.', newItem: 'New', upload: 'Upload', uploadFiles: 'Upload files', uploadDirectory: 'Upload folder', dropToUpload: 'Drop to upload here', dropUploadDestination: 'Files and folders will be uploaded to the current directory.', download: 'Download', refresh: 'Refresh', back: 'Back', forward: 'Forward', parentDirectory: 'Parent directory', path: 'Path', itemType: 'Item type', itemName: 'Name', createItem: 'Create', createLocation: 'Create in: {path}', invalidFileName: 'The name cannot be empty, . or .., contain / or control characters, or exceed 255 bytes.', filterFiles: 'Filter this directory', searchDirectory: 'Search this directory', showHidden: 'Show hidden files', managedPathWarning: 'This path is platform-managed. Changes may be replaced by restart, update, or runtime rebuild and can damage the current deployment.', locations: 'Locations', selectAll: 'Select all', fileName: 'Name', fileSize: 'Size', fileOwner: 'User:group', fileModified: 'Modified', fileMode: 'Mode', calculateSize: 'Calculate', calculatingSize: 'Calculating', sizeCalculationFailed: 'Failed', emptyDirectory: 'This directory is empty.', loadMore: 'Load more', itemsPerPage: 'Items per page', itemsPerPageSuffix: '/ page', previousPage: 'Previous', nextPage: 'Next', totalItems: '{total} total', goToPage: 'Go to', pageUnit: 'page', searchSystemPlugins: 'Search System Plugins', searchSystemSkills: 'Search System Skills', searchUserPlugins: 'Search User Plugins', searchUserSkills: 'Search User Skills', noMatchingResources: 'No items match this search.', noFilesSelected: 'No files selected', filesSelected: '{count} selected', copy: 'Copy', cut: 'Cut', paste: 'Paste', compress: 'Compress', extract: 'Extract', rename: 'Rename', deletePermanently: 'Delete permanently', compressItems: 'Compress selected items', extractArchive: 'Extract archive', archiveFormat: 'Archive format', archiveName: 'Archive name', invalidArchiveName: 'Enter a valid archive name.', unsupportedArchive: 'Select a ZIP, 7z, or tar.gz file.', editPermissions: 'Edit permissions', permissions: 'Permissions', permissionRead: 'Read', permissionWrite: 'Write', permissionExecute: 'Execute', permissionOwner: 'Owner', permissionGroup: 'Group', permissionOthers: 'Others', fileUser: 'User', fileGroup: 'Group', recursiveAttributes: 'Also change child attributes', applyPermissions: 'Apply permissions', attributesInvalid: 'Enter a valid user, group, and a 3- or 4-digit octal mode.', attributesOperation: 'Changing file attributes', newFile: 'New file', newDirectory: 'New directory', enterName: 'Enter a name', searchRunning: 'Searching directory', taskRunning: 'Running {operation}', uploadProgress: 'Uploading {current} / {total}', fileOperations: 'File operations', queuedOperation: 'Queue position {position}', processingOperation: 'Processing', cancelOperation: 'Cancel operation', operationComplete: 'File operation completed', operationFailed: 'File operation failed', attributesUnsupported: 'This mount does not support changing Unix ownership or permissions. Use a Linux/WSL path or named volume with Unix metadata support.', confirmDeleteFiles: 'Permanently delete {count} selected items? This cannot be undone.', operationSucceeded: 'Completed', operationCancelled: 'Cancelled', operationFailedState: 'Failed', editFile: 'Edit file', fileContent: 'File content', close: 'Close', reload: 'Reload', saveAs: 'Save as', save: 'Save', renameItem: 'Rename item', renameItemDetail: 'Enter a new name for the selected item.', saveAsTitle: 'Save as', saveAsDetail: 'Enter the absolute container path to save.', discardChangesTitle: 'Discard unsaved changes', unsavedFile: 'Discard unsaved file changes?', discardChanges: 'Discard changes', deleteFilesTitle: 'Delete permanently', confirmDelete: 'Delete', fileSaved: 'File saved', fileRevisionChanged: 'The file changed in another process. Reload it or save as a new file.', clipboardCopy: '{count} items copied. Open the destination and choose Paste.', clipboardMove: '{count} items cut. Open the destination and choose Paste.', fileEditorUnsaved: 'Unsaved changes', fileEditorSaved: 'All changes saved', fileEditorSaving: 'Saving', fileConflictTitle: 'Destination already exists', fileConflictDetail: 'Choose how to handle:', conflictOverwrite: 'Overwrite', conflictOverwriteDetail: 'Replace the existing item.', conflictRename: 'Auto rename', conflictRenameDetail: 'Keep both items with a new name.', conflictSkip: 'Skip', conflictSkipDetail: 'Leave the existing item unchanged.', conflictApplyAll: 'Apply to all remaining conflicts', confirmChoice: 'Confirm', operationCompleteWithSkipped: 'File operation completed; skipped {count} conflicting items.', online: 'Connected', connecting: 'Reconnecting', offline: 'Disconnected', }), }) function preferredLocale() { const override = storageValue(LANGUAGE_KEY) if (override === 'zh' || override === 'en') return override return String(navigator.language ?? '').toLowerCase().split('-', 1)[0] === 'zh' ? 'zh' : 'en' } const locale = preferredLocale() let themePreference = (() => { const value = storageValue(THEME_KEY) return value === 'light' || value === 'dark' ? value : 'system' })() const elements = Object.fromEntries([...document.querySelectorAll('[id]')].map(element => [element.id, element])) const channelButtons = [...document.querySelectorAll('[data-channel]')] const tabButtons = [...document.querySelectorAll('[data-tab]')] elements['language-switch'].value = locale const customSelectMenus = new Set() const customSelectRefreshers = new Set() const customSelectValueSyncs = new WeakMap() const nativeSelectValue = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value') Object.defineProperty(HTMLSelectElement.prototype, 'value', { configurable: nativeSelectValue.configurable, enumerable: nativeSelectValue.enumerable, get: nativeSelectValue.get, set(value) { nativeSelectValue.set.call(this, value) customSelectValueSyncs.get(this)?.() }, }) let openCustomSelect = null function enhanceSelect(select) { if (select.closest('.custom-select')) return const originalWidth = select.getBoundingClientRect().width const wrapper = document.createElement('span') wrapper.className = 'custom-select' if (select.id === 'language-switch') wrapper.classList.add('language-switch-wrapper') if (select.closest('.list-pagination, .file-pagination')) wrapper.classList.add('pagination-select-wrapper') if (originalWidth > 0) wrapper.style.width = `${originalWidth}px` select.parentNode.insertBefore(wrapper, select) wrapper.append(select) select.classList.add('custom-select-native') const trigger = document.createElement('button') trigger.type = 'button' trigger.className = 'custom-select-trigger' if (select.id === 'language-switch') trigger.classList.add('language-switch-trigger') const triggerLabel = document.createElement('span') triggerLabel.className = 'custom-select-label' trigger.append(triggerLabel) trigger.setAttribute('aria-haspopup', 'listbox') trigger.setAttribute('aria-expanded', 'false') trigger.setAttribute('aria-controls', `${select.id}-menu`) wrapper.append(trigger) const menu = document.createElement('div') menu.id = `${select.id}-menu` menu.className = 'custom-select-menu' if (select.id === 'language-switch') menu.classList.add('language-switch-menu') if (select.closest('.list-pagination, .file-pagination')) menu.classList.add('pagination-select-menu') if (select.closest('.log-filters')) menu.classList.add('log-filter-menu') menu.setAttribute('role', 'listbox') document.body.append(menu) customSelectMenus.add(menu) const sync = () => { const selected = select.options[select.selectedIndex] triggerLabel.textContent = selected?.textContent ?? '' trigger.setAttribute('aria-label', select.getAttribute('aria-label') ?? '') for (const option of menu.children) option.setAttribute('aria-selected', String(option.dataset.value === select.value)) } const render = () => { menu.replaceChildren(...[...select.options].map(option => { const item = document.createElement('button') item.type = 'button' item.className = 'custom-select-option' item.dataset.value = option.value item.textContent = option.textContent item.setAttribute('role', 'option') item.addEventListener('click', () => { select.value = option.value select.dispatchEvent(new Event('change', { bubbles: true })) sync() close() }) return item })) sync() } customSelectRefreshers.add(render) customSelectValueSyncs.set(select, sync) const close = () => { menu.dataset.open = 'false' trigger.setAttribute('aria-expanded', 'false') if (openCustomSelect === close) openCustomSelect = null } const position = () => { const rect = trigger.getBoundingClientRect() const gap = 4 const maxHeight = Math.min(window.innerHeight * .45, 360) menu.style.maxHeight = `${maxHeight}px` menu.style.width = 'max-content' menu.style.minWidth = `${rect.width}px` const menuWidth = Math.min(Math.max(rect.width, menu.getBoundingClientRect().width), window.innerWidth - 8) menu.style.width = `${menuWidth}px` const menuHeight = Math.min(menu.scrollHeight, maxHeight) const below = window.innerHeight - rect.bottom - gap const above = rect.top - gap const top = below >= menuHeight || below >= above ? rect.bottom + gap : rect.top - menuHeight - gap menu.style.left = `${Math.max(4, Math.min(rect.left, window.innerWidth - menuWidth - 4))}px` menu.style.top = `${Math.max(4, top)}px` } menu.addEventListener('reposition', position) trigger.addEventListener('click', () => { if (menu.dataset.open === 'true') return close() if (openCustomSelect) openCustomSelect() render() menu.dataset.open = 'true' position() trigger.setAttribute('aria-expanded', 'true') openCustomSelect = close }) select.addEventListener('change', sync) render() } for (const select of document.querySelectorAll('select')) enhanceSelect(select) document.addEventListener('click', event => { if (!event.target.closest('.custom-select')) openCustomSelect?.() }) window.addEventListener('resize', () => openCustomSelect && document.querySelector('.custom-select-menu[data-open="true"]') && [...customSelectMenus].forEach(menu => { if (menu.dataset.open === 'true') menu.dispatchEvent(new Event('reposition')) })) makeHorizontalTabStripScrollable(document.querySelector('.tabs')) makeLogListVerticallyResizable(elements['log-resize-frame'], elements['log-resize-handle']) const RUNTIME_RESET_PHASES = Object.freeze({ 'runtime-reset-building': Object.freeze({ progress: 20, label: 'runtimeResetBuilding' }), 'runtime-reset-verifying': Object.freeze({ progress: 55, label: 'runtimeResetVerifying' }), 'runtime-reset-switching': Object.freeze({ progress: 70, label: 'runtimeResetSwitching' }), 'runtime-reset-starting': Object.freeze({ progress: 85, label: 'runtimeResetStarting' }), 'runtime-reset-recovering': Object.freeze({ progress: 90, label: 'runtimeResetRecovering' }), }) let status let plugins = [] let systemSkills = [] let userSkillInventory = { revision: null, skills: [] } let userPluginInventory = { revision: null, plugins: [] } let proxyConfiguration let proxyProviderInventory = { providers: [] } let proxyLoaded = false let proxyLoading let proxyProviderLoading let proxyTestTask let proxyTestPollTimer const expandedProxyTestStages = new Set() let proxyImmediateSave = Promise.resolve() let proxySaveStateTimer const inventoriesLoaded = { plugins: false, systemSkills: false, userSkills: false, userPlugins: false } const inventoryLoadRevisions = { plugins: 0, systemSkills: 0, userSkills: 0, userPlugins: 0 } const LIST_PAGE_SIZES = Object.freeze([5, 10, 20, 50]) const LIST_PAGE_SIZE_KEY_PREFIX = 'dsh-platform:console-page-size:' const listPages = { plugins: 0, systemSkills: 0, userSkills: 0, userPlugins: 0 } const listQueries = { plugins: '', systemSkills: '', userSkills: '', userPlugins: '' } const listPageSizes = Object.fromEntries(Object.keys(listPages).map(key => { const value = Number(storageValue(`${LIST_PAGE_SIZE_KEY_PREFIX}${key}`)) return [key, LIST_PAGE_SIZES.includes(value) ? value : 10] })) const userPluginDraft = new Map() const userPluginApplyingDraft = new Map() const expandedUserPluginDescriptions = new Set() const expandedUserPluginMetadata = new Set() const systemPluginDraft = new Map() const expandedUserSkillDescriptions = new Set() const expandedUserSkillMetadata = new Set() const expandedSystemPluginDescriptions = new Set() const expandedSystemSkillDescriptions = new Set() let rollbackPlan let statusLoad let statusLoadRevision = 0 const inventoryLoads = { plugins: undefined, systemSkills: undefined, userSkills: undefined, userPlugins: undefined } let checking = false let acting = false let discardingPluginDraft = false let userPluginSubmitting = false let systemPluginSubmitting = false let systemPluginProgress const systemPluginApplyingDraft = new Map() let userPluginFeedback = null const visibleOperationTasks = new Set() const operationResultTimers = new Map() let eventSource let logSource let logModuleCatalog = [] let progressLogSource let progressLogKey let progressLogEntries = [] const progressLogIdentities = new Set() let progressLogUpdate const progressLogStageExpansion = new Map() const progressLogStageTouched = new Set() let progressLogAutoScroll = true let progressSuccessTimer let progressSuccessTimerKey let progressSuccessDismissedKey let dismissedProgressTaskId let logLastActivity = 0 let logWatchdogTimer let logRenderFrame let autoScroll = true let terminalRuntime let terminalRuntimeLoad let terminalStyleLoad let terminalRestore let terminalEmulator let terminalFit let terminalSocket let terminalSessionId let terminalSessionState = 'idle' let terminalReconnectTimer let terminalReconnectDeadline let terminalResizeObserver let terminalResizeFrame let terminalRestored = false let terminalLeaving = false let runtimeResetExpanded = false let runtimeResetProgress = 0 let filesLoaded = false let fileConfigLoad let fileLoading = false let filePath = '/workspace' let fileListing = { revision: null, entries: [], nextCursor: null, total: 0 } let fileSort = 'name' let fileOrder = 'asc' let filePageSize = 100 let filePageIndex = 0 let fileHistory = [] let fileFuture = [] let fileCreateExpanded = false let fileCreateKind = 'touch' let fileSelected = new Set() let fileClipboard = null const fileDirectorySizes = new Map() let fileActiveTask = null let fileTasksActive = [] let fileUploadQueue = [] let fileDragDepth = 0 let fileTaskRefreshTimer let fileArchiveMode = null let fileEditor = null let fileEditorOriginal = '' let fileEditorDirty = false let fileEditorSaving = false let fileOperationTimer let authStatusTimer let fileAttributesEntry = null let fileConflictResolve = null let textInputResolve let textInputValidate let confirmationResolve const logEntries = [] const logIdentities = new Set() const expandedLogIdentities = new Set() let logDisplayLimit = (() => { const value = Number(storageValue(LOG_DISPLAY_LIMIT_KEY)) return LOG_DISPLAY_LIMITS.includes(value) ? value : DEFAULT_LOG_DISPLAY_LIMIT })() let logClearCutoff = (() => { try { const value = window.sessionStorage.getItem(LOG_CLEAR_CUTOFF_KEY) return Number.isFinite(Date.parse(value)) ? value : null } catch { return null } })() function t(key, values = {}) { let result = COPY[locale][key] ?? COPY.en[key] ?? key for (const [name, value] of Object.entries(values)) result = result.replaceAll(`{${name}}`, String(value)) return result } function applyTranslations() { document.documentElement.lang = locale === 'zh' ? 'zh-CN' : 'en' document.title = t('title') for (const node of document.querySelectorAll('[data-i18n]')) node.textContent = t(node.dataset.i18n) for (const node of document.querySelectorAll('[data-i18n-placeholder]')) node.placeholder = t(node.dataset.i18nPlaceholder) for (const node of document.querySelectorAll('[data-i18n-aria-label]')) node.setAttribute('aria-label', t(node.dataset.i18nAriaLabel)) for (const node of document.querySelectorAll('[data-i18n-title]')) node.setAttribute('title', t(node.dataset.i18nTitle)) for (const node of document.querySelectorAll('[data-i18n-alt]')) node.setAttribute('alt', t(node.dataset.i18nAlt)) for (const node of document.querySelectorAll('[data-log-limit]')) node.textContent = t('logDisplayLimitValue', { count: node.dataset.logLimit }) for (const refresh of customSelectRefreshers) refresh() elements['language-switch'].value = locale renderThemeControl() refreshProxyDescriptions() } const THEME_ORDER = Object.freeze(['system', 'light', 'dark']) function renderThemeControl() { const currentIndex = THEME_ORDER.indexOf(themePreference) const next = THEME_ORDER[(currentIndex + 1) % THEME_ORDER.length] const label = t('themeButtonLabel', { current: t(`theme${themePreference[0].toUpperCase()}${themePreference.slice(1)}`), next: t(`theme${next[0].toUpperCase()}${next.slice(1)}`) }) elements['theme-switch'].dataset.themePreference = themePreference elements['theme-switch'].setAttribute('aria-label', label) elements['theme-switch'].title = label } function applyTheme(preference) { if (preference === 'light' || preference === 'dark') document.documentElement.dataset.theme = preference else delete document.documentElement.dataset.theme if (terminalEmulator !== undefined) terminalEmulator.options.theme = terminalTheme() } function display(value) { return value === undefined || value === null || value === '' ? '-' : String(value) } function displayEnvironment(value) { return value === undefined || value === null || value === '' ? '-' : `env-${String(value)}` } function makeHorizontalTabStripScrollable(tablist) { let pointerId let pointerStartX = 0 let scrollStart = 0 let wheelTarget = tablist.scrollLeft let wheelFrame let wheelFrameTime let dragged = false let dragTarget let suppressClickTarget const stopWheelAnimation = () => { if (wheelFrame !== undefined) window.cancelAnimationFrame(wheelFrame) wheelFrame = undefined wheelFrameTime = undefined wheelTarget = tablist.scrollLeft } const animateWheel = timestamp => { const elapsed = wheelFrameTime === undefined ? 16 : Math.min(timestamp - wheelFrameTime, 32) const remaining = wheelTarget - tablist.scrollLeft if (Math.abs(remaining) < 0.5) { tablist.scrollLeft = wheelTarget wheelFrame = undefined wheelFrameTime = undefined return } tablist.scrollLeft += remaining * (1 - Math.exp(-elapsed / 45)) wheelFrameTime = timestamp wheelFrame = window.requestAnimationFrame(animateWheel) } const finishDrag = event => { if (pointerId === undefined || (event.pointerId !== undefined && event.pointerId !== pointerId)) return if (tablist.hasPointerCapture?.(pointerId)) tablist.releasePointerCapture(pointerId) const displacedClickTarget = !dragged && event.target !== dragTarget ? dragTarget : undefined suppressClickTarget = dragged ? dragTarget : undefined if (suppressClickTarget !== undefined) window.setTimeout(() => { suppressClickTarget = undefined }, 0) pointerId = undefined dragged = false dragTarget = undefined displacedClickTarget?.click() } tablist.addEventListener('wheel', event => { if (tablist.scrollWidth <= tablist.clientWidth) return if (event.deltaX !== 0) return let delta = event.deltaY if (delta === 0) return if (event.deltaMode === WheelEvent.DOM_DELTA_LINE) delta *= 16 else if (event.deltaMode === WheelEvent.DOM_DELTA_PAGE) delta *= tablist.clientWidth if (wheelFrame === undefined) wheelTarget = tablist.scrollLeft const nextTarget = Math.max(0, Math.min(tablist.scrollWidth - tablist.clientWidth, wheelTarget + delta)) if (nextTarget === wheelTarget) return wheelTarget = nextTarget if (wheelFrame === undefined) wheelFrame = window.requestAnimationFrame(animateWheel) event.preventDefault() }, { passive: false }) tablist.addEventListener('pointerdown', event => { if (event.button !== 0 || event.pointerType === 'touch' || tablist.scrollWidth <= tablist.clientWidth) return stopWheelAnimation() pointerId = event.pointerId pointerStartX = event.clientX scrollStart = tablist.scrollLeft dragged = false dragTarget = event.target }) tablist.addEventListener('pointermove', event => { if (event.pointerId !== pointerId) return const distance = event.clientX - pointerStartX if (Math.abs(distance) >= 4 && !dragged) { dragged = true tablist.setPointerCapture?.(pointerId) } if (!dragged) return tablist.scrollLeft = scrollStart - distance event.preventDefault() }) tablist.addEventListener('pointerup', finishDrag) tablist.addEventListener('pointercancel', finishDrag) tablist.addEventListener('click', event => { if (event.target !== suppressClickTarget) return suppressClickTarget = undefined event.preventDefault() event.stopPropagation() }, true) } function makeLogListVerticallyResizable(element, handle) { let pointerId let startY = 0 let startHeight = 0 let startScrollTop = 0 let lastClientY = 0 let minimumHeight = 0 let maximumHeight = 0 let scrollFrame let previousCursor = '' let previousUserSelect = '' const scrollContainer = (() => { for (let candidate = element.parentElement; candidate !== null; candidate = candidate.parentElement) { const overflowY = window.getComputedStyle(candidate).overflowY if (/(auto|scroll)/u.test(overflowY)) return candidate } return document.scrollingElement })() const keepBottomVisible = () => { if (scrollFrame !== undefined) window.cancelAnimationFrame(scrollFrame) scrollFrame = window.requestAnimationFrame(() => { scrollFrame = undefined if (scrollContainer === null) return const viewportBottom = scrollContainer === document.scrollingElement ? window.innerHeight : scrollContainer.getBoundingClientRect().bottom const overflow = element.getBoundingClientRect().bottom - viewportBottom + 8 if (overflow <= 0) return const previousScrollTop = scrollContainer.scrollTop scrollContainer.scrollTop += Math.min(overflow, 24) const height = Math.min(maximumHeight, Math.max(minimumHeight, startHeight + lastClientY - startY + scrollContainer.scrollTop - startScrollTop)) element.style.height = `${String(height)}px` if (scrollContainer.scrollTop > previousScrollTop && (pointerId === undefined || lastClientY >= viewportBottom - 24)) keepBottomVisible() }) } handle.addEventListener('pointerdown', event => { event.preventDefault() pointerId = event.pointerId startY = event.clientY lastClientY = event.clientY startHeight = element.getBoundingClientRect().height startScrollTop = scrollContainer?.scrollTop ?? 0 const style = window.getComputedStyle(element) minimumHeight = Number.parseFloat(style.minHeight) maximumHeight = Number.parseFloat(style.maxHeight) previousCursor = document.body.style.cursor previousUserSelect = document.body.style.userSelect document.body.style.cursor = 'ns-resize' document.body.style.userSelect = 'none' handle.setPointerCapture?.(pointerId) }) window.addEventListener('pointermove', event => { if (pointerId === undefined || event.pointerId !== pointerId) return lastClientY = event.clientY const height = Math.min(maximumHeight, Math.max(minimumHeight, startHeight + lastClientY - startY + (scrollContainer?.scrollTop ?? 0) - startScrollTop)) element.style.height = `${String(height)}px` keepBottomVisible() }) const finishResize = event => { if (pointerId === undefined || event.pointerId !== pointerId) return if (handle.hasPointerCapture?.(pointerId)) handle.releasePointerCapture(pointerId) pointerId = undefined document.body.style.cursor = previousCursor document.body.style.userSelect = previousUserSelect keepBottomVisible() } window.addEventListener('pointerup', finishResize) window.addEventListener('pointercancel', finishResize) } function localTime(value) { if (!value) return '-' const date = new Date(value) return Number.isNaN(date.valueOf()) ? String(value) : date.toLocaleString(locale === 'zh' ? 'zh-CN' : 'en-US') } const UPDATE_PROGRESS_STEPS = Object.freeze(['progressPrepare', 'progressAcquire', 'progressBuild', 'progressActivate']) const UPDATE_PROGRESS_STAGE = Object.freeze({ checking: 0, planning: 0, 'checking-upstream': 0, downloading: 1, validating: 1, 'building-candidate': 2, 'snapshotting-data': 3, switching: 3, probation: 3, 'restoring-data': 3, }) const ROLLBACK_PROGRESS_STEPS = Object.freeze(['rollbackPrepare', 'rollbackSwitch', 'rollbackData', 'rollbackVerify']) const ROLLBACK_PROGRESS_STAGE = Object.freeze({ preparing: 0, stopping: 0, switching: 1, 'restoring-data': 2, verifying: 3 }) function isRecoveryOperation(operation) { return operation === 'rollback' || operation === 'return-stable' } function probationRemainingSeconds(update) { const detail = String(update?.detail ?? '').match(/^probation:(\d+)$/u) if (detail !== null) return Number(detail[1]) const deadline = Date.parse(update?.probationUntil ?? '') return Number.isFinite(deadline) ? Math.max(0, Math.ceil((deadline - Date.now()) / 1_000)) : undefined } function progressLogPhase(update) { return update?.phase ?? (isRecoveryOperation(update?.operation) ? update?.rollbackPhase : update?.status) } function progressLogStage(phase, update = progressLogUpdate) { const rollback = isRecoveryOperation(update?.operation) const stageMap = rollback ? ROLLBACK_PROGRESS_STAGE : UPDATE_PROGRESS_STAGE const labels = rollback ? ROLLBACK_PROGRESS_STEPS : UPDATE_PROGRESS_STEPS let index = stageMap[phase] if (index === undefined) return { key: `phase:${String(phase ?? 'unknown')}`, label: String(phase ?? t('stageLogs')), index: labels.length } if (rollback && update?.rollbackIncludesSnapshot === false && index > 2) index -= 1 const visibleLabels = rollback && update?.rollbackIncludesSnapshot === false ? labels.filter(key => key !== 'rollbackData') : labels return { key: `${rollback ? 'rollback' : 'update'}:${String(index)}`, label: t(visibleLabels[index] ?? 'stageLogs'), index } } function progressLogText(entry) { const message = entry?.message ?? entry?.event ?? '' return String(message).replace(/\s+/gu, ' ').trim() || '-' } function progressStageDefinitions(update) { const rollback = isRecoveryOperation(update?.operation) const labels = rollback ? ROLLBACK_PROGRESS_STEPS.filter(key => update.rollbackIncludesSnapshot !== false || key !== 'rollbackData') : UPDATE_PROGRESS_STEPS return labels.map((label, index) => ({ key: `${rollback ? 'rollback' : 'update'}:${String(index)}`, label: t(label), labelKey: label, index, })) } const UPDATE_STAGE_ITEMS = Object.freeze({ progressPrepare: ['itemVerifyMetadata', 'itemVerifyKeyring', 'itemVerifyTarget'], progressAcquire: ['itemDownloadArtifacts', 'itemVerifyArtifacts', 'itemImportObjects'], progressBuild: ['itemMaterializePristine', 'itemPrepareEnvironment', 'itemBuildRuntime', 'itemPreparePlugins'], progressActivate: ['itemSwitchDeployment', 'itemCheckHealth', 'itemObserveProbation'], }) const UPDATE_RECOVERY_STAGE_ITEMS = Object.freeze([ 'itemSwitchPrevious', 'itemRestoreSnapshot', 'itemStartRuntime', 'itemCheckHealth', ]) const ROLLBACK_STAGE_ITEMS = Object.freeze({ rollbackPrepare: ['itemValidateRollback', 'itemPauseRuntime'], rollbackSwitch: ['itemSwitchPrevious'], rollbackData: ['itemVerifySnapshot', 'itemRestoreSnapshot'], rollbackVerify: ['itemStartRuntime', 'itemCheckHealth'], }) function activeStageItemIndex(stage, update) { const phase = progressLogPhase(update) if (stage.labelKey === 'progressPrepare') return phase === 'planning' ? 2 : 0 if (stage.labelKey === 'progressAcquire') return phase === 'validating' ? 2 : 0 if (stage.labelKey === 'progressBuild') { const progress = Number(update?.progress) || 0 if (Number(update?.totalBytes) > 0 || Number(update?.totalItems) > 0) return progress >= 87 ? 3 : 2 if (progress < 80) return 0 if (progress < 82) return 1 if (progress < 87) return 2 return 3 } if (stage.labelKey === 'progressActivate') { if (phase === 'restoring-data') { return { 'recovery:suspend': 0, 'recovery:deployment': 0, 'recovery:snapshot': 1, 'recovery:runtime': 2, 'recovery:health': 3, }[update?.detail] ?? 0 } if (phase === 'probation') return 2 if (Number(update?.totalServices) > 0) return 1 return update?.rollbackIncludesSnapshot === true && phase === 'snapshotting-data' ? 0 : 0 } if (stage.labelKey === 'rollbackPrepare') return phase === 'stopping' ? 1 : 0 if (stage.labelKey === 'rollbackSwitch') return 0 if (stage.labelKey === 'rollbackData') return Number(update?.totalItems) > 0 || Number(update?.totalBytes) > 0 ? 1 : 0 if (stage.labelKey === 'rollbackVerify') return Number(update?.totalServices) > 0 ? 1 : 0 return 0 } function stageItems(stage, update, state) { const keys = stage.labelKey === 'progressActivate' && progressLogPhase(update) === 'restoring-data' ? UPDATE_RECOVERY_STAGE_ITEMS : (stage.key.startsWith('rollback') ? ROLLBACK_STAGE_ITEMS : UPDATE_STAGE_ITEMS)[stage.labelKey] ?? ['stageCompleted'] const active = activeStageItemIndex(stage, update) return keys.map((key, index) => ({ key, label: t(key), state: state === 'completed' ? 'completed' : state === 'pending' ? 'pending' : index < active ? 'completed' : index === active ? (state === 'failed' ? 'failed' : 'active') : 'pending', })) } function stageMetricLines(value, stage, state) { const lines = [] const bytes = Number(value?.processedBytes) const totalBytes = Number(value?.totalBytes) const items = Number(value?.processedItems) const totalItems = Number(value?.totalItems) const ready = Number(value?.readyServices) const totalServices = Number(value?.totalServices) const completeEnough = state !== 'completed' || stageMetricProgress(value) === 100 if (stage.index > 0 && completeEnough && Number.isFinite(bytes) && Number.isFinite(totalBytes) && totalBytes > 0) { const key = stage.index === 1 && !stage.key.startsWith('rollback') ? 'metricBytesRead' : stage.index === 2 && !stage.key.startsWith('rollback') ? 'metricBytesCopied' : stage.key.startsWith('rollback') && stage.label === t('rollbackData') ? 'metricBytesRestored' : 'metricBytesProcessed' lines.push(t(key, { processed: fileSize(bytes), total: fileSize(totalBytes) })) } if (stage.index > 0 && completeEnough && Number.isFinite(items) && Number.isFinite(totalItems) && totalItems > 0) { const key = stage.index === 1 && !stage.key.startsWith('rollback') ? 'metricArtifacts' : stage.index >= 2 || stage.key.startsWith('rollback') ? 'metricFiles' : 'metricItems' lines.push(t(key, { processed: String(items), total: String(totalItems) })) } if (Number.isFinite(ready) && Number.isFinite(totalServices) && totalServices > 0) { lines.push(t('metricServices', { ready: String(ready), total: String(totalServices) })) } if (stage.labelKey === 'progressActivate' && progressLogPhase(value) === 'probation') { const seconds = probationRemainingSeconds(value) if (seconds !== undefined) lines.push(t('metricProbationRemaining', { seconds: String(seconds) })) } return lines } function stageMetricProgress(value) { for (const [processedKey, totalKey] of [['processedBytes', 'totalBytes'], ['processedItems', 'totalItems'], ['readyServices', 'totalServices']]) { const processed = Number(value?.[processedKey]) const total = Number(value?.[totalKey]) if (Number.isFinite(processed) && Number.isFinite(total) && total > 0) { return Math.max(0, Math.min(100, Math.round(processed / total * 100))) } } return undefined } function progressLogTime(value) { const date = new Date(value) return Number.isNaN(date.valueOf()) ? '-' : localTime(value) } function transactionStageState(index, currentIndex, status) { if (index < currentIndex || (index === currentIndex && status === 'success')) return 'completed' if (index === currentIndex) return status === 'failed' ? 'failed' : 'active' return 'pending' } function renderProgressLogs() { const panel = elements['progress-stage-log'] if (!panel) return panel.hidden = progressLogKey === undefined if (panel.hidden) return const activeStage = progressLogStage(progressLogPhase(progressLogUpdate)) elements['progress-log-list'].replaceChildren() const groups = new Map() for (const entry of progressLogEntries) { const stage = progressLogStage(entry.phase) const group = groups.get(stage.key) ?? { ...stage, entries: [] } group.entries.push(entry) groups.set(stage.key, group) } const failed = progressLogUpdate?.status === 'failed' const definitions = progressStageDefinitions(progressLogUpdate) for (const definition of definitions) { const group = groups.get(definition.key) ?? { ...definition, entries: [] } const state = transactionStageState(group.index, activeStage.index, progressLogUpdate?.status) group.labelKey = definition.labelKey const latest = group.entries.at(-1) const metricSource = state === 'active' || state === 'failed' ? { ...latest, ...progressLogUpdate } : latest const metricLines = stageMetricLines(metricSource, group, state) const items = stageItems(group, progressLogUpdate, state) const completedItems = items.filter(item => item.state === 'completed').length const stageDetails = document.createElement('details') stageDetails.className = `progress-log-group ${state}` stageDetails.dataset.stageKey = group.key const defaultExpanded = state === 'active' || state === 'failed' stageDetails.open = progressLogStageExpansion.get(group.key) ?? defaultExpanded const stageSummary = document.createElement('summary') const marker = document.createElement('span') marker.className = `progress-stage-marker ${state}` marker.setAttribute('aria-hidden', 'true') const summaryBody = document.createElement('span') summaryBody.className = 'progress-stage-summary' const stageName = document.createElement('strong') stageName.textContent = group.label summaryBody.append(stageName) const count = document.createElement('span') count.className = 'progress-stage-count' count.textContent = t('stageItemsCompleted', { completed: String(completedItems), total: String(items.length) }) summaryBody.append(count) const toggle = document.createElement('button') toggle.type = 'button' toggle.className = 'progress-stage-toggle' toggle.setAttribute('aria-expanded', String(stageDetails.open)) toggle.textContent = t(stageDetails.open ? 'collapseStage' : 'expandStage', { count: String(group.entries.length) }) toggle.addEventListener('click', event => { event.preventDefault() event.stopPropagation() progressLogStageTouched.add(group.key) const nextOpen = !stageDetails.open progressLogStageExpansion.set(group.key, nextOpen) stageDetails.open = nextOpen toggle.setAttribute('aria-expanded', String(nextOpen)) toggle.textContent = t(nextOpen ? 'collapseStage' : 'expandStage', { count: String(group.entries.length) }) }) stageSummary.append(marker, summaryBody, toggle) const checklist = document.createElement('div') checklist.className = 'progress-stage-items' for (const item of items) { const row = document.createElement('div') row.className = `progress-stage-item ${item.state}` const itemMarker = document.createElement('span') itemMarker.className = 'progress-stage-item-marker' itemMarker.textContent = '' const itemBody = document.createElement('span') itemBody.textContent = t({ completed: 'stageItemCompleted', active: 'stageItemActive', failed: 'stageItemFailed', pending: 'stageItemPending' }[item.state], { item: item.label }) row.append(itemMarker, itemBody) if (item.state === 'active' || item.state === 'failed') { for (const text of metricLines) { const metric = document.createElement('span') metric.className = 'progress-stage-metric' metric.textContent = text row.append(metric) } const value = item.state === 'active' ? stageMetricProgress(metricSource) : undefined if (value !== undefined) { const metric = document.createElement('span') metric.className = 'progress-stage-percent' metric.textContent = t('stageProgress', { progress: String(value) }) row.append(metric) } } checklist.append(row) } if (state === 'failed') { const error = document.createElement('p') error.className = 'progress-stage-error' error.textContent = localizedError(progressLogUpdate?.error ?? latest?.error ?? t('statusFailed')) checklist.append(error) } const entries = document.createElement('div') entries.className = `progress-log-group-list${group.entries.length > 0 ? ' populated' : ''}` if (group.entries.length === 0) { const empty = document.createElement('p') empty.className = 'progress-log-empty' empty.textContent = t('noStageLogs') entries.append(empty) } for (const entry of group.entries.slice(-200)) { const details = document.createElement('details') details.className = 'progress-log-entry' const summary = document.createElement('summary') const time = document.createElement('time') time.textContent = progressLogTime(entry.timestamp) const level = document.createElement('span') level.className = `progress-log-level ${String(entry.level ?? 'info').toLowerCase()}` const levelValue = String(entry.level ?? 'info').toLowerCase() level.textContent = t(`level${levelValue[0].toUpperCase()}${levelValue.slice(1)}`) const source = document.createElement('span') source.className = 'progress-log-source' source.textContent = String(entry.source ?? '-') const message = document.createElement('span') message.className = 'progress-log-message' message.textContent = progressLogText(entry) const chevron = document.createElement('span') chevron.className = 'progress-log-chevron' chevron.setAttribute('aria-hidden', 'true') summary.append(level, source, time, message, chevron) const body = document.createElement('pre') body.textContent = JSON.stringify(entry, null, 2) details.append(summary, body) entries.append(details) } const actions = document.createElement('div') actions.className = 'progress-log-actions' const autoScroll = document.createElement('label') autoScroll.className = 'progress-auto-scroll' const autoScrollInput = document.createElement('input') autoScrollInput.type = 'checkbox' autoScrollInput.checked = progressLogAutoScroll autoScrollInput.addEventListener('change', event => { progressLogAutoScroll = event.target.checked if (progressLogAutoScroll) renderProgressLogs() }) const autoScrollText = document.createElement('span') autoScrollText.textContent = t('autoScroll') autoScroll.append(autoScrollInput, autoScrollText) const copy = document.createElement('button') copy.type = 'button' copy.className = 'secondary' copy.disabled = group.entries.length === 0 copy.textContent = t('copyStageLogs') copy.addEventListener('click', async () => { try { await navigator.clipboard.writeText(group.entries.map(entry => JSON.stringify(entry)).join('\n')) copy.textContent = t('logsCopied') window.setTimeout(() => { copy.textContent = t('copyStageLogs') }, 1_500) } catch {} }) actions.append(autoScroll, copy) stageDetails.append(stageSummary, checklist, entries, actions) stageSummary.addEventListener('click', () => { progressLogStageTouched.add(group.key) }) stageDetails.addEventListener('toggle', () => { progressLogStageExpansion.set(group.key, stageDetails.open) toggle.setAttribute('aria-expanded', String(stageDetails.open)) toggle.textContent = t(stageDetails.open ? 'collapseStage' : 'expandStage', { count: String(group.entries.length) }) }) elements['progress-log-list'].append(stageDetails) } const activeExpanded = progressLogStageExpansion.get(activeStage.key) ?? true if (progressLogAutoScroll && activeExpanded) { window.requestAnimationFrame(() => { const activeGroup = [...elements['progress-log-list'].querySelectorAll('.progress-log-group')] .find(group => group.dataset.stageKey === activeStage.key) const list = activeGroup?.querySelector('.progress-log-group-list') if (list) list.scrollTop = list.scrollHeight }) } } function closeProgressLogs() { progressLogSource?.close() progressLogSource = undefined progressLogKey = undefined progressLogEntries = [] progressLogIdentities.clear() progressLogUpdate = undefined progressLogStageExpansion.clear() progressLogStageTouched.clear() renderProgressLogs() } function connectProgressLogs(update) { const phase = progressLogPhase(update) const taskId = update.taskId if (!taskId || !phase) { closeProgressLogs() return } const key = String(taskId) const previousStatus = progressLogUpdate?.status const previousActiveStage = progressLogUpdate === undefined ? undefined : progressLogStage(progressLogPhase(progressLogUpdate)).key progressLogUpdate = update const activeStage = progressLogStage(phase) if (update.status === 'success') { if (previousStatus !== 'success') { progressLogStageTouched.delete(activeStage.key) progressLogStageExpansion.set(activeStage.key, false) } } else if (!progressLogStageTouched.has(activeStage.key)) { progressLogStageExpansion.set(activeStage.key, true) } if (previousActiveStage !== undefined && previousActiveStage !== activeStage.key && !progressLogStageTouched.has(previousActiveStage)) { progressLogStageExpansion.set(previousActiveStage, false) } if (progressLogKey === key && progressLogSource !== undefined) { renderProgressLogs() return } progressLogSource?.close() progressLogEntries = [] progressLogIdentities.clear() progressLogStageExpansion.clear() progressLogStageTouched.clear() progressLogStageExpansion.set(activeStage.key, update.status !== 'success') progressLogKey = key const params = new URLSearchParams({ taskId: String(taskId), operation: String(update.operation ?? 'update'), limit: '1000' }) progressLogSource = new EventSource(`${API}/logs/stream?${params.toString()}`) progressLogSource.addEventListener('log', event => { try { const entry = JSON.parse(event.data) const identity = JSON.stringify(entry) if (progressLogIdentities.has(identity)) return progressLogIdentities.add(identity) progressLogEntries.push(entry) renderProgressLogs() } catch {} }) progressLogSource.onerror = () => {} renderProgressLogs() } function fileSize(value) { const size = Number(value) if (!Number.isFinite(size)) return '-' if (size < 1024) return `${String(size)} B` if (size < 1024 ** 2) return `${(size / 1024).toFixed(1)} KiB` if (size < 1024 ** 3) return `${(size / 1024 ** 2).toFixed(1)} MiB` return `${(size / 1024 ** 3).toFixed(1)} GiB` } function progressMetrics(update) { const metrics = [] const processedBytes = Number(update.processedBytes) const totalBytes = Number(update.totalBytes) const processedItems = Number(update.processedItems) const totalItems = Number(update.totalItems) const readyServices = Number(update.readyServices) const totalServices = Number(update.totalServices) if (Number.isFinite(processedBytes) && Number.isFinite(totalBytes) && totalBytes > 0) { metrics.push(t('metricBytes', { processed: fileSize(processedBytes), total: fileSize(totalBytes) })) } if (Number.isFinite(processedItems) && Number.isFinite(totalItems) && totalItems > 0) { metrics.push(t('metricItems', { processed: String(processedItems), total: String(totalItems) })) } if (Number.isFinite(readyServices) && Number.isFinite(totalServices) && totalServices > 0) { metrics.push(t('metricServices', { ready: String(readyServices), total: String(totalServices) })) } return metrics.join(' · ') } function updateOutcome(value) { const key = { none: 'outcomeNone', frozen: 'outcomeFrozen', held: 'outcomeHeld', blocked: 'outcomeBlocked', stable: 'outcomeStable', experimental: 'outcomeExperimental', }[value] return key === undefined ? display(value) : t(key) } function localizedError(value) { const message = value instanceof Error ? value.message : String(value) if (locale === 'en') return message const httpStatus = message.match(/HTTP\s+(\d{3})/i)?.[1] return httpStatus === undefined ? t('operationError') : `${t('requestError')}(HTTP ${httpStatus})` } function setText(id, value) { elements[id].textContent = display(value) } function setConnection(state) { elements.connection.dataset.state = state elements.connection.querySelector('strong').textContent = t(state) } function showError(error) { elements.error.textContent = localizedError(error) elements.error.hidden = false } function showAuthenticationStatus(message) { window.clearTimeout(authStatusTimer) elements['auth-settings-status'].textContent = message elements['auth-settings-status'].hidden = false authStatusTimer = window.setTimeout(() => { elements['auth-settings-status'].hidden = true elements['auth-settings-status'].textContent = '' }, 3_000) } function clearError() { elements.error.hidden = true elements.error.textContent = '' } function startManagementSessionGuard() { let checking = false const inspect = async () => { if (checking) return checking = true try { const response = await fetch(`${managementLoginPath()}/session-context`, { cache: 'no-store', credentials: 'same-origin', }) if (response.ok) { try { window.sessionStorage.removeItem(MANAGEMENT_TRANSITION_TARGET_KEY) } catch {} } else if (response.status === 401 || response.status === 404) { let pendingTarget try { pendingTarget = window.sessionStorage.getItem(MANAGEMENT_TRANSITION_TARGET_KEY) } catch {} if (pendingTarget !== null && pendingTarget !== undefined && pendingTarget !== '') { try { window.sessionStorage.removeItem(MANAGEMENT_TRANSITION_TARGET_KEY) } catch {} window.location.replace(pendingTarget) return } if (response.status === 401) window.location.reload() } } catch {} finally { checking = false } } setInterval(inspect, 5_000) window.addEventListener('focus', inspect) document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') inspect() }) void inspect() } function browserCookie(name) { for (const part of document.cookie.split(';')) { const separator = part.indexOf('=') if (separator >= 0 && part.slice(0, separator).trim() === name) return part.slice(separator + 1).trim() } return '' } async function api(path, { method = 'GET', body } = {}) { const mutation = !['GET', 'HEAD', 'OPTIONS'].includes(method) const response = await fetch(`${API}/${path}`, { method, headers: { ...(body === undefined ? {} : { 'content-type': 'application/json' }), ...(mutation ? { 'x-dsh-csrf': browserCookie('dsh_management_csrf') } : {}), }, body: body === undefined ? undefined : JSON.stringify(body), }) const value = await response.json() const errorCode = value?.error?.code ?? value?.code if (response.status === 401 && !['FRESH_AUTH_FAILED', 'TOTP_INVALID', 'TOTP_ENROLLMENT_INVALID', 'TOTP_DISABLE_CONFIRMATION_INVALID'].includes(errorCode)) { window.location.reload() throw new Error(t('authSessionExpired')) } if (!response.ok) { const detail = value?.error const error = new Error(typeof detail === 'object' && detail !== null ? detail.message ?? `HTTP ${String(response.status)}` : detail ?? `HTTP ${String(response.status)}`) error.statusCode = response.status error.code = detail?.code ?? value.code error.stage = detail?.stage error.retryable = detail?.retryable === true error.retryAfterSeconds = detail?.retryAfterSeconds ?? value.retryAfterSeconds throw error } return value } function runtimeBusy(next = status) { const update = next?.update ?? {} return (acting && !checking) || (!UPDATE_TERMINAL_STATES.has(update.status ?? 'idle') && update.status !== 'checking') || next?.systemPluginOperation?.status === 'running' || next?.systemSkillOperation?.status === 'running' || next?.userSkillOperation?.status === 'running' || next?.userPluginOperation?.status === 'running' || ['starting', 'stopping', 'restarting', 'recovering'].includes(next?.dshLifecycle?.state) || next?.runtimeReset?.status === 'resetting' } function setRuntimeResetExpanded(expanded) { runtimeResetExpanded = expanded elements['runtime-reset-confirmation'].hidden = !expanded elements['runtime-reset'].setAttribute('aria-expanded', String(expanded)) elements['runtime-reset'].textContent = t(expanded ? 'cancelRuntimeReset' : 'runtimeReset') } function operationResultVisible(operation, activeStatus) { const operationState = operation?.status ?? operation?.state if (operationState === activeStatus) { if (operation.taskId) { visibleOperationTasks.add(operation.taskId) window.clearTimeout(operationResultTimers.get(operation.taskId)) operationResultTimers.delete(operation.taskId) } return true } const taskId = operation?.taskId const visible = taskId !== null && taskId !== undefined && visibleOperationTasks.has(taskId) if (visible && (operationState === 'success' || operation?.state === 'running') && !operationResultTimers.has(taskId)) { operationResultTimers.set(taskId, window.setTimeout(() => { operationResultTimers.delete(taskId) visibleOperationTasks.delete(taskId) if (status !== undefined) render(status) }, 3_000)) } return visible } function hasTaskId(operation) { return typeof operation?.taskId === 'string' && operation.taskId.length > 0 } function holdReason(hold) { return locale === 'en' ? display(hold.reason) : t(hold.type === 'combination' ? 'holdCombination' : 'holdVersion') } function renderHolds(values, busy) { elements.holds.replaceChildren() elements.holds.hidden = values.length === 0 for (const hold of values) { const row = document.createElement('div') row.className = 'hold' const copy = document.createElement('div') const identity = document.createElement('strong') identity.textContent = `${display(hold.dshVersion)}${hold.environmentVersion ? ` + ${displayEnvironment(hold.environmentVersion)}` : ''}` const reason = document.createElement('span') reason.textContent = holdReason(hold) copy.append(identity, reason) const retry = document.createElement('button') retry.type = 'button' retry.className = 'compact' retry.textContent = t('retry') retry.disabled = busy retry.addEventListener('click', () => { void act('holds/retry', { method: 'POST', body: { id: hold.id } }) }) row.append(copy, retry) elements.holds.append(row) } } function pluginDescription(plugin) { return plugin.description?.[locale] ?? plugin.id } function resourceSearchValues(key, value) { if (key === 'plugins' || key === 'systemSkills') return [value.id] if (key === 'userSkills') return [value.name ?? value.entryName] return [value.name] } function filteredResources(key, values) { const query = listQueries[key].trim().toLocaleLowerCase(locale) if (query === '') return values return values.filter(value => resourceSearchValues(key, value) .some(part => String(part ?? '').toLocaleLowerCase(locale).includes(query))) } function toggleExpandedElement(element, identity, expanded) { const isExpanded = !expanded.has(identity) preserveScrollableAncestors(element, () => { if (isExpanded) expanded.add(identity) else expanded.delete(identity) element.classList.toggle('expanded', isExpanded) element.setAttribute('aria-expanded', String(isExpanded)) }) } const stableScrollPositions = new WeakMap() const scrollRestoreGenerations = new WeakMap() const trackedScrollableElements = new WeakSet() function trackScrollablePosition(element) { if (trackedScrollableElements.has(element)) return trackedScrollableElements.add(element) stableScrollPositions.set(element, { left: element.scrollLeft, top: element.scrollTop }) element.addEventListener('scroll', () => { if (scrollRestoreGenerations.has(element)) return stableScrollPositions.set(element, { left: element.scrollLeft, top: element.scrollTop }) }, { passive: true }) } function captureScrollablePositions(element, includeElement = false) { const positions = [] for (let current = includeElement ? element : element.parentElement; current !== null; current = current.parentElement) { if (current === element || current.scrollHeight > current.clientHeight || current.scrollWidth > current.clientWidth) { trackScrollablePosition(current) if (!scrollRestoreGenerations.has(current)) { stableScrollPositions.set(current, { left: current.scrollLeft, top: current.scrollTop }) } const position = stableScrollPositions.get(current) const generation = (scrollRestoreGenerations.get(current) ?? 0) + 1 scrollRestoreGenerations.set(current, generation) positions.push([current, position.left, position.top, generation]) } } return positions } function restoreScrollablePositions(positions) { const restore = () => { for (const [current, left, top] of positions) { current.scrollLeft = left current.scrollTop = top } } restore() window.requestAnimationFrame(() => window.requestAnimationFrame(() => { restore() for (const [current, left, top, generation] of positions) { if (scrollRestoreGenerations.get(current) !== generation) continue scrollRestoreGenerations.delete(current) stableScrollPositions.set(current, { left, top }) } })) } function preserveScrollableAncestors(element, update) { const positions = captureScrollablePositions(element) update() restoreScrollablePositions(positions) } function preserveResourceRowPosition(element, update) { const row = element.closest('article') const panel = element.closest('.resource-panel') const identity = row?.querySelector('strong')?.textContent const top = row?.getBoundingClientRect().top if (panel === null) { update() return } const positions = [] for (let current = element.parentElement; current !== null; current = current.parentElement) { positions.push([current, current.scrollLeft, current.scrollTop]) if (current === panel) break } update() const restore = () => { for (const [current, left, scrollTop] of positions) { current.scrollLeft = left current.scrollTop = scrollTop } } restore() window.requestAnimationFrame(() => window.requestAnimationFrame(() => { restore() const nextRow = identity === undefined ? undefined : [...(panel?.querySelectorAll('article') ?? [])] .find(candidate => candidate.querySelector('strong')?.textContent === identity) if (nextRow === undefined || top === undefined) return for (const [current, left] of positions) { const offset = nextRow.getBoundingClientRect().top - top if (Math.abs(offset) <= 1) break current.scrollLeft = left if (current.scrollHeight > current.clientHeight) current.scrollTop += offset } })) } function refreshProxyDescriptions() { for (const description of document.querySelectorAll('.proxy-scope-description')) { if (description.dataset.expandListener !== 'true') { description.dataset.expandListener = 'true' description.setAttribute('aria-expanded', 'false') description.addEventListener('click', () => { if (!description.classList.contains('expandable')) return preserveScrollableAncestors(description, () => { const expanded = !description.classList.contains('expanded') description.classList.toggle('expanded', expanded) description.setAttribute('aria-expanded', String(expanded)) }) }) } window.requestAnimationFrame(() => { if (!description.isConnected || description.classList.contains('expanded')) return const clone = description.cloneNode(true) clone.classList.remove('expandable', 'expanded') Object.assign(clone.style, { position: 'fixed', visibility: 'hidden', width: `${description.clientWidth}px`, height: 'auto', maxHeight: 'none', overflow: 'visible', display: 'block', webkitLineClamp: 'unset', pointerEvents: 'none', }) document.body.append(clone) const fullHeight = clone.scrollHeight clone.remove() description.classList.toggle('expandable', fullHeight > description.clientHeight + 1) }) } } function expandableResourceDescription(text, identity, expanded) { const description = document.createElement('button') const isExpanded = expanded.has(identity) description.type = 'button' description.className = `resource-description${isExpanded ? ' expanded expandable' : ''}` description.textContent = text description.title = text description.setAttribute('aria-expanded', String(isExpanded)) description.addEventListener('click', () => { if (!description.classList.contains('expandable')) return toggleExpandedElement(description, identity, expanded) }) if (!isExpanded) window.requestAnimationFrame(() => { if (description.isConnected && description.scrollWidth > description.clientWidth) description.classList.add('expandable') }) return description } function expandableUserMetadata(metadata, identity, expanded) { const isExpanded = expanded.has(identity) metadata.className = `user-plugin-metadata${isExpanded ? ' expanded expandable' : ''}` metadata.setAttribute('aria-expanded', String(isExpanded)) const toggle = event => { if (!metadata.classList.contains('expandable')) return if (event.type === 'keydown' && event.key !== 'Enter' && event.key !== ' ') return event.preventDefault() toggleExpandedElement(metadata, identity, expanded) } metadata.addEventListener('click', toggle) metadata.addEventListener('keydown', toggle) const enable = () => { metadata.classList.add('expandable') metadata.tabIndex = 0 metadata.setAttribute('role', 'button') } if (isExpanded) enable() else window.requestAnimationFrame(() => { if (metadata.isConnected && [...metadata.querySelectorAll('dd')].some(value => value.scrollWidth > value.clientWidth)) enable() }) return metadata } function paginated(key, values) { const pageSize = listPageSizes[key] const lastPage = Math.max(0, Math.ceil(values.length / pageSize) - 1) listPages[key] = Math.min(listPages[key], lastPage) const start = listPages[key] * pageSize const prefix = { plugins: 'plugins', systemSkills: 'system-skills', userSkills: 'user-skills', userPlugins: 'user-plugins' }[key] elements[`${prefix}-pagination`].hidden = false elements[`${prefix}-page-size`].value = String(pageSize) elements[`${prefix}-page-status`].textContent = t('totalItems', { total: values.length }) elements[`${prefix}-page-current`].textContent = String(listPages[key] + 1) elements[`${prefix}-page-jump`].max = String(lastPage + 1) if (document.activeElement !== elements[`${prefix}-page-jump`]) elements[`${prefix}-page-jump`].value = String(listPages[key] + 1) elements[`${prefix}-page-previous`].disabled = listPages[key] === 0 elements[`${prefix}-page-next`].disabled = listPages[key] === lastPage return values.slice(start, start + pageSize) } function projectedSystemPlugin(plugin) { const action = systemPluginDraft.get(plugin.id) if (action === 'install') return { ...plugin, installed: true, enabled: true } if (action === 'uninstall') return { ...plugin, installed: false, enabled: false } if (action === 'enable') return { ...plugin, enabled: true } if (action === 'disable') return { ...plugin, enabled: false } return plugin } function setSystemPluginDraft(plugin, action) { systemPluginProgress = undefined systemPluginApplyingDraft.clear() if ( (action === 'install' && plugin.installed) || (action === 'uninstall' && !plugin.installed) || (action === 'enable' && plugin.installed && plugin.enabled) || (action === 'disable' && plugin.installed && !plugin.enabled) ) systemPluginDraft.delete(plugin.id) else systemPluginDraft.set(plugin.id, action) if (status !== undefined) render(status) else renderBundledPlugins(plugins, runtimeBusy()) } function systemPluginSummary() { if (systemPluginProgress?.phase === 'restarting') return t('systemPluginRestarting') if (systemPluginProgress?.phase === 'applying') { const actionKey = { install: 'pluginActionInstall', uninstall: 'pluginActionUninstall', enable: 'pluginActionEnable', disable: 'pluginActionDisable', }[systemPluginProgress.action] return t('systemPluginApplyingItem', { action: t(actionKey ?? 'pluginActionWorking'), id: systemPluginProgress.id, current: systemPluginProgress.current, total: systemPluginProgress.total, }) } return systemPluginDraft.size > 0 ? t('pendingSystemPluginChanges', { count: systemPluginDraft.size }) : t('pluginChangesPendingDetail') } function reconcileSystemPluginProgress(next) { if (systemPluginProgress?.phase !== 'restarting' || systemPluginProgress.taskId === undefined) return const lifecycle = next?.dshLifecycle if (lifecycle?.taskId !== systemPluginProgress.taskId) return if (!['running', 'failed', 'stopped'].includes(lifecycle.state)) return systemPluginProgress = undefined systemPluginApplyingDraft.clear() window.sessionStorage.removeItem(PLUGIN_DRAFT_KEY) } function pluginButton(label, plugin, action, busy, className = 'secondary') { const button = document.createElement('button') button.type = 'button' button.className = className button.textContent = label button.disabled = busy button.addEventListener('click', event => preserveResourceRowPosition(event.currentTarget, () => setSystemPluginDraft(plugin, action))) return button } function renderBundledPlugins(values, busy) { const scrollPositions = captureScrollablePositions(elements['bundled-plugins'], true) const filtered = filteredResources('plugins', values) const restartRequired = systemPluginDraft.size > 0 || values.some(plugin => plugin.pendingRestart) elements['bundled-plugins'].replaceChildren() elements['empty-plugins'].hidden = filtered.length !== 0 elements['empty-plugins'].textContent = values.length === 0 ? t('noSystemPlugins') : t('noMatchingResources') elements['bundled-plugins'].hidden = filtered.length === 0 const operation = status?.systemPluginOperation ?? {} for (const plugin of paginated('plugins', filtered)) { const action = systemPluginDraft.get(plugin.id) const projected = projectedSystemPlugin(plugin) const isActive = operation.status === 'running' && operation.pluginId === plugin.id const applyingAction = restartRequired ? systemPluginApplyingDraft.get(plugin.id) ?? (isActive ? operation.action : undefined) : isActive ? operation.action : undefined const row = document.createElement('article') row.className = 'plugin-row' const identity = document.createElement('div') identity.className = 'plugin-identity' const name = document.createElement('strong') name.textContent = `@dsh-docker/${plugin.id}` const heading = document.createElement('div') heading.className = 'resource-heading' const stateKey = applyingAction !== undefined ? { install: 'statusInstalling', uninstall: 'statusUninstalling', enable: 'statusEnabling', disable: 'statusDisabling' }[applyingAction] : action !== undefined ? { install: 'pendingInstall', uninstall: 'pendingUninstall', enable: 'pendingEnable', disable: 'pendingDisable' }[action] : plugin.installed ? (plugin.enabled ? 'resourceEnabled' : 'resourceDisabled') : 'notInstalled' heading.append( userPluginBadge(t(stateKey ?? 'pluginActionWorking'), applyingAction !== undefined || action !== undefined ? 'pending' : plugin.enabled ? 'enabled' : ''), name, ) identity.append( heading, expandableResourceDescription(pluginDescription(plugin), plugin.id, expandedSystemPluginDescriptions), ) const controls = document.createElement('div') controls.className = 'plugin-actions' if (!projected.installed) { controls.append(pluginButton(t(action === 'uninstall' ? 'cancelChanges' : 'installPlugin'), plugin, 'install', busy, 'primary')) } else { const toggle = document.createElement('label') toggle.className = 'toggle' const checkbox = document.createElement('input') checkbox.type = 'checkbox' checkbox.checked = projected.enabled checkbox.disabled = busy || action === 'install' checkbox.setAttribute('aria-label', `${name.textContent}: ${projected.enabled ? t('userPluginEnabled') : t('userPluginDisabled')}`) checkbox.addEventListener('change', event => preserveResourceRowPosition(event.currentTarget, () => setSystemPluginDraft(plugin, event.target.checked ? 'enable' : 'disable'))) const track = document.createElement('span') track.setAttribute('aria-hidden', 'true') toggle.append(checkbox, track) controls.append(toggle, pluginButton(t(action === 'install' ? 'cancelChanges' : 'uninstallPlugin'), plugin, 'uninstall', busy, 'danger-text')) } row.append(identity, controls) elements['bundled-plugins'].append(row) } restoreScrollablePositions(scrollPositions) } function renderSystemSkills(values, busy) { const scrollPositions = captureScrollablePositions(elements['system-skills'], true) const filtered = filteredResources('systemSkills', values) elements['system-skills'].replaceChildren() elements['empty-skills'].hidden = filtered.length !== 0 elements['empty-skills'].textContent = values.length === 0 ? t('noSystemSkills') : t('noMatchingResources') elements['system-skills'].hidden = filtered.length === 0 const operation = status?.systemSkillOperation ?? {} for (const skill of paginated('systemSkills', filtered)) { const isActive = operation.status === 'running' && operation.skillId === skill.id const row = document.createElement('article') row.className = 'plugin-row' const identity = document.createElement('div') identity.className = 'plugin-identity' const name = document.createElement('strong') name.textContent = skill.id const heading = document.createElement('div') heading.className = 'resource-heading' const stateKey = isActive ? { install: 'statusInstalling', uninstall: 'statusUninstalling', enable: 'statusEnabling', disable: 'statusDisabling' }[operation.action] : skill.installed ? (skill.enabled ? 'resourceEnabled' : 'resourceDisabled') : 'notInstalled' heading.append( userPluginBadge(t(stateKey ?? 'skillActionWorking'), isActive ? 'pending' : skill.enabled ? 'enabled' : ''), name, ) identity.append( heading, expandableResourceDescription(skill.description?.[locale] ?? skill.id, skill.id, expandedSystemSkillDescriptions), ) const controls = document.createElement('div') controls.className = 'plugin-actions' const actionButton = (label, action, className = 'secondary') => { const button = document.createElement('button') button.type = 'button' button.className = className button.textContent = label button.disabled = busy button.addEventListener('click', () => { void runSkillTask('system-skills/action', { skillId: skill.id, action }, 'systemSkillOperation') }) return button } if (!skill.installed) controls.append(actionButton(t('installPlugin'), 'install', 'primary')) else { const toggle = document.createElement('label') toggle.className = 'toggle' const checkbox = document.createElement('input') checkbox.type = 'checkbox' checkbox.checked = skill.enabled checkbox.disabled = busy checkbox.setAttribute('aria-label', `${name.textContent}: ${skill.enabled ? t('userSkillEnabled') : t('userSkillDisabled')}`) checkbox.addEventListener('change', event => { void runSkillTask('system-skills/action', { skillId: skill.id, action: event.target.checked ? 'enable' : 'disable', }, 'systemSkillOperation') }) const track = document.createElement('span') track.setAttribute('aria-hidden', 'true') toggle.append(checkbox, track) controls.append(toggle, actionButton(t('uninstallPlugin'), 'uninstall', 'danger-text')) } row.append(identity, controls) elements['system-skills'].append(row) } restoreScrollablePositions(scrollPositions) } function userSkillSource(source) { return t(source === 'user-dsh' ? 'userSkillSourceDsh' : 'userSkillSourceAgents') } async function runUserSkillAction(skill, action) { if (action === 'delete') { const confirmed = await requestConfirmation({ title: t('deleteUserSkillTitle'), detail: t('deleteUserSkillDetail', { name: skill.name ?? skill.entryName }), confirmLabel: t('deleteUserSkill'), danger: true, }) if (!confirmed) return } await runSkillTask('user-skills/action', { entryId: skill.entryId, revision: userSkillInventory.revision, action, }, 'userSkillOperation') } function renderUserSkills(busy) { const scrollPositions = captureScrollablePositions(elements['user-skills'], true) const values = userSkillInventory.skills ?? [] const filtered = filteredResources('userSkills', values) const operation = status?.userSkillOperation ?? {} const locked = busy || operation.status === 'running' elements['user-skills'].replaceChildren() elements['empty-user-skills'].hidden = filtered.length !== 0 elements['empty-user-skills'].textContent = values.length === 0 ? t('noUserSkills') : t('noMatchingResources') elements['user-skills'].hidden = filtered.length === 0 for (const skill of paginated('userSkills', filtered)) { const row = document.createElement('article') row.className = 'user-plugin-row' const identity = document.createElement('div') identity.className = 'user-plugin-main' const heading = document.createElement('div') heading.className = 'user-plugin-heading' heading.append(userPluginBadge(t(skill.enabled ? 'userSkillEnabled' : 'userSkillDisabled'), skill.enabled ? 'enabled' : '')) const name = document.createElement('strong') name.textContent = skill.name ?? skill.entryName const description = document.createElement('button') const descriptionExpanded = expandedUserSkillDescriptions.has(skill.entryId) description.type = 'button' description.className = `user-plugin-description${descriptionExpanded ? ' expanded expandable' : ''}` description.textContent = skill.description ?? localizedError(skill.metadataError ?? t('userSkillDamaged')) description.title = description.textContent description.setAttribute('aria-expanded', String(descriptionExpanded)) description.addEventListener('click', () => { if (!description.classList.contains('expandable')) return toggleExpandedElement(description, skill.entryId, expandedUserSkillDescriptions) }) heading.append(name) if (!descriptionExpanded) window.requestAnimationFrame(() => { if (description.isConnected && description.scrollWidth > description.clientWidth) description.classList.add('expandable') }) const metadata = document.createElement('dl') for (const [label, value] of [[t('userSkillSource'), userSkillSource(skill.source)], [t('userSkillEntry'), skill.entryName]]) { const field = document.createElement('div') const term = document.createElement('dt') term.textContent = label const detail = document.createElement('dd') detail.textContent = value field.append(term, detail) metadata.append(field) } expandableUserMetadata(metadata, skill.entryId, expandedUserSkillMetadata) identity.append(heading, description, metadata) const controls = document.createElement('div') controls.className = 'plugin-actions' const toggle = document.createElement('label') toggle.className = 'toggle' const checkbox = document.createElement('input') checkbox.type = 'checkbox' checkbox.checked = skill.enabled checkbox.disabled = locked checkbox.setAttribute('aria-label', `${skill.enabled ? t('userSkillEnabled') : t('userSkillDisabled')}: ${skill.name ?? skill.entryName}`) checkbox.addEventListener('change', () => { void runUserSkillAction(skill, checkbox.checked ? 'enable' : 'disable') }) const track = document.createElement('span') track.setAttribute('aria-hidden', 'true') const label = document.createElement('strong') label.textContent = skill.enabled ? t('userSkillEnabled') : t('userSkillDisabled') toggle.append(checkbox, track, label) const deleteButton = document.createElement('button') deleteButton.type = 'button' deleteButton.className = 'danger-text' deleteButton.textContent = t('deleteUserSkill') deleteButton.disabled = locked deleteButton.addEventListener('click', () => { void runUserSkillAction(skill, 'delete') }) controls.append(toggle, deleteButton) row.append(identity, controls) elements['user-skills'].append(row) } restoreScrollablePositions(scrollPositions) } function userPluginSource(value) { return t({ registry: 'userPluginSourceRegistry', file: 'userPluginSourceFile', git: 'userPluginSourceGit', url: 'userPluginSourceUrl', other: 'userPluginSourceOther', }[value] ?? 'userPluginSourceOther') } function setUserPluginDraft(plugin, action) { if ((action === 'enable' && plugin.enabled) || (action === 'disable' && !plugin.enabled)) userPluginDraft.delete(plugin.name) else userPluginDraft.set(plugin.name, action) userPluginFeedback = null renderUserPlugins(runtimeBusy()) } function userPluginBadge(label, className = '') { const badge = document.createElement('span') badge.className = `user-plugin-badge ${className}`.trim() badge.textContent = label return badge } function renderUserPlugins(busy) { const scrollPositions = captureScrollablePositions(elements['user-plugin-list'], true) const values = userPluginInventory.plugins ?? [] const filtered = filteredResources('userPlugins', values) const operation = status?.userPluginOperation ?? {} const locked = busy || userPluginSubmitting || operation.status === 'running' elements['user-plugin-list'].replaceChildren() elements['user-plugin-list'].hidden = filtered.length === 0 elements['empty-user-plugins'].hidden = filtered.length !== 0 elements['empty-user-plugins'].textContent = values.length === 0 ? t('noUserPlugins') : t('noMatchingResources') for (const plugin of paginated('userPlugins', filtered)) { const action = userPluginDraft.get(plugin.name) const applyingAction = userPluginApplyingDraft.get(plugin.name) const row = document.createElement('article') row.className = `user-plugin-row${action ? ' pending' : ''}` const identity = document.createElement('div') identity.className = 'user-plugin-main' const heading = document.createElement('div') heading.className = 'user-plugin-heading' const name = document.createElement('strong') name.textContent = plugin.name const badge = applyingAction ? userPluginBadge(t({ enable: 'statusEnabling', disable: 'statusDisabling', uninstall: 'statusUninstalling' }[applyingAction]), 'pending') : action ? userPluginBadge(t({ enable: 'pendingEnable', disable: 'pendingDisable', uninstall: 'pendingUninstall' }[action]), 'pending') : plugin.pendingRestart ? userPluginBadge(t('pluginPendingRestart'), 'pending') : plugin.reservedNameConflict ? userPluginBadge(t('userPluginReserved'), 'danger') : plugin.damaged ? userPluginBadge(t('userPluginDamaged'), 'warning') : userPluginBadge(plugin.enabled ? t('userPluginEnabled') : t('userPluginDisabled'), plugin.enabled ? 'enabled' : '') heading.append(badge, name) identity.append(heading) if (plugin.description) { const description = document.createElement('button') const descriptionExpanded = expandedUserPluginDescriptions.has(plugin.name) description.type = 'button' description.className = `user-plugin-description${descriptionExpanded ? ' expanded expandable' : ''}` description.textContent = plugin.description description.title = plugin.description description.setAttribute('aria-expanded', String(descriptionExpanded)) description.addEventListener('click', () => { if (!description.classList.contains('expandable')) return toggleExpandedElement(description, plugin.name, expandedUserPluginDescriptions) }) identity.append(description) if (!descriptionExpanded) window.requestAnimationFrame(() => { if (description.isConnected && description.scrollWidth > description.clientWidth) description.classList.add('expandable') }) } const metadata = document.createElement('dl') for (const [label, value] of [ [t('userPluginVersion'), plugin.version], [t('userPluginSpec'), plugin.spec], [t('userPluginSource'), userPluginSource(plugin.source)], ]) { const field = document.createElement('div') const term = document.createElement('dt') term.textContent = label const description = document.createElement('dd') description.textContent = display(value) field.append(term, description) metadata.append(field) } expandableUserMetadata(metadata, plugin.name, expandedUserPluginMetadata) identity.append(metadata) if (plugin.metadataError) { const detail = document.createElement('p') detail.className = 'user-plugin-error' detail.textContent = locale === 'en' ? plugin.metadataError : t('userPluginMetadataError') identity.append(detail) } const controls = document.createElement('div') controls.className = 'user-plugin-controls' const toggle = document.createElement('label') toggle.className = 'toggle' const checkbox = document.createElement('input') checkbox.type = 'checkbox' checkbox.checked = action === 'enable' || (action !== 'disable' && plugin.enabled) checkbox.disabled = locked || action === 'uninstall' || (!plugin.enabled && (plugin.damaged || plugin.reservedNameConflict)) checkbox.setAttribute('aria-label', `${plugin.name}: ${checkbox.checked ? t('userPluginEnabled') : t('userPluginDisabled')}`) checkbox.addEventListener('change', event => preserveResourceRowPosition(event.currentTarget, () => setUserPluginDraft(plugin, event.target.checked ? 'enable' : 'disable'))) const track = document.createElement('span') track.setAttribute('aria-hidden', 'true') toggle.append(checkbox, track) const uninstall = document.createElement('button') uninstall.type = 'button' uninstall.className = action === 'uninstall' ? 'secondary' : 'danger-text' uninstall.textContent = t(action === 'uninstall' ? 'cancelUninstall' : 'uninstallUserPlugin') uninstall.setAttribute('aria-label', `${uninstall.textContent}: ${plugin.name}`) uninstall.disabled = locked uninstall.addEventListener('click', event => preserveResourceRowPosition(event.currentTarget, () => { if (action === 'uninstall') userPluginDraft.delete(plugin.name) else userPluginDraft.set(plugin.name, 'uninstall') userPluginFeedback = null renderUserPlugins(runtimeBusy()) })) controls.append(toggle, uninstall) row.append(identity, controls) elements['user-plugin-list'].append(row) } const phaseKey = { validated: 'userPluginPhaseValidated', paused: 'userPluginPhasePaused', snapshotted: 'userPluginPhaseSnapshotted', mutating: 'userPluginPhaseMutating', committed: 'userPluginPhaseCommitted', restarting: 'userPluginPhaseRestarting', restoring: 'userPluginPhaseRestoring', }[operation.phase] const count = userPluginDraft.size const restartRequired = userPluginInventory.restartRequired === true const applying = operation.status === 'running' elements['user-plugin-draft-actions'].hidden = count === 0 && !restartRequired && !applying elements['user-plugin-draft-summary'].textContent = applying ? t(phaseKey ?? 'userPluginApplying') : count === 0 ? t('userPluginRestartRequiredDetail') : t('pendingUserPluginChanges', { count }) elements['cancel-user-plugin-changes'].disabled = locked || count === 0 elements['apply-user-plugin-changes'].disabled = locked || (count === 0 && !restartRequired) const dshUnavailable = status?.recoveryMode !== null && status?.recoveryMode !== undefined || ['stopped', 'failed'].includes(status?.dshLifecycle?.state) elements['apply-user-plugin-changes'].textContent = t(dshUnavailable ? 'applyUserPluginChangesOffline' : 'applyUserPluginChanges') const dshFailed = status?.recoveryMode !== null && status?.recoveryMode !== undefined || status?.dshLifecycle?.state === 'failed' elements['user-plugin-recovery'].hidden = !dshFailed elements['user-plugin-recovery-detail'].textContent = t('userPluginRecoveryDetail') const operationVisible = operationResultVisible(operation, 'running') const feedback = operation.status === 'running' ? t(phaseKey ?? 'userPluginApplying') : operationVisible && operation.status === 'failed' ? `${t('userPluginApplyFailed')}: ${localizedError(operation.error ?? '')}` : operationVisible && operation.status === 'success' ? t(operation.strategy === 'next-start' ? 'userPluginApplyDeferred' : 'userPluginApplyComplete') : userPluginFeedback elements['user-plugin-operation'].textContent = feedback ?? '' elements['user-plugin-operation'].hidden = !feedback restoreScrollablePositions(scrollPositions) } function render(next) { const activeResourcePanel = document.querySelector('.resource-panel:not([hidden])') const resourcePanelScrollPositions = activeResourcePanel === null ? [] : captureScrollablePositions(activeResourcePanel, true) reconcileSystemPluginProgress(next) status = next rollbackPlan = next.rollbackPlan const update = next.update ?? {} const restart = next.dshLifecycle ?? {} const runtimeReset = next.runtimeReset ?? {} const restartVisible = ['starting', 'stopping', 'restarting'].some(state => operationResultVisible(restart, state)) const runtimeResetVisible = operationResultVisible(runtimeReset, 'resetting') const pluginOperation = next.systemPluginOperation ?? {} const skillOperation = next.systemSkillOperation ?? {} const userSkillOperation = next.userSkillOperation ?? {} const pluginOperationVisible = operationResultVisible(pluginOperation, 'running') const busy = runtimeBusy(next) const updateActive = !UPDATE_TERMINAL_STATES.has(update.status ?? 'idle') const successTimerKey = update.status === 'success' && update.taskId ? `${String(update.taskId)}:${String(update.updatedAt)}` : undefined const failedDismissed = update.status === 'failed' && String(update.taskId ?? '') === dismissedProgressTaskId if (successTimerKey !== progressSuccessTimerKey) { if (progressSuccessTimer !== undefined) window.clearTimeout(progressSuccessTimer) progressSuccessTimer = undefined progressSuccessTimerKey = successTimerKey if (successTimerKey !== undefined && successTimerKey !== progressSuccessDismissedKey) { progressSuccessTimer = window.setTimeout(() => { progressSuccessDismissedKey = successTimerKey progressSuccessTimer = undefined progressSuccessTimerKey = undefined if (status !== undefined) render(status) }, 3_000) } } const successVisible = successTimerKey !== undefined && successTimerKey !== progressSuccessDismissedKey && progressSuccessTimer !== undefined const progressVisible = updateActive || (update.status === 'failed' && Boolean(update.taskId) && !failedDismissed) || successVisible const checkingUpdates = checking || update.status === 'checking' if (restart.state === 'running' && hasTaskId(restart) && !plugins.some(plugin => plugin.pendingRestart)) { window.sessionStorage.removeItem(PLUGIN_DRAFT_KEY) } const hasSupportedTarget = next.supported !== null && next.supported !== undefined setText('current-dsh', next.current?.dsh) setText('current-env', displayEnvironment(next.current?.environment)) setText('supported-dsh', next.supported?.dsh) setText('supported-env', displayEnvironment(next.supported?.environment)) setText('upstream-dsh', next.upstream?.version) const experimental = next.updateChannel === 'experimental' elements.versions.classList.toggle('experimental', experimental) elements['upstream-version'].hidden = !experimental for (const button of channelButtons) { button.setAttribute('aria-pressed', String(button.dataset.channel === next.updateChannel)) button.disabled = busy } elements['checked-at'].textContent = update.checkedAt ? `${t('lastChecked')} ${localTime(update.checkedAt)}` : t('notChecked') elements['check-spinner'].hidden = !checkingUpdates elements['check-label'].textContent = checkingUpdates ? t('checking') : t('check') elements.check.disabled = busy elements.update.textContent = experimental ? t('updateUpstream') : t('updateSupported') elements.update.disabled = busy || update.metadataUnavailable || !hasSupportedTarget || update.updateAvailable !== true elements.rollback.hidden = rollbackPlan === null || rollbackPlan === undefined elements.rollback.disabled = busy elements['return-stable'].hidden = !rollbackPlan?.returnStableAvailable elements['return-stable'].disabled = busy const progress = Math.max(0, Math.min(100, Number(update.progress) || 0)) elements['update-progress'].hidden = !progressVisible elements['progress-value'].value = `${String(progress)}%` elements['progress-value'].textContent = `${String(progress)}%` elements.progress.setAttribute('aria-valuenow', String(progress)) elements.progress.dataset.complete = String(progress === 100) elements['progress-bar'].style.width = `${String(progress)}%` elements['progress-dismiss'].hidden = update.status !== 'failed' || !progressVisible if (progressVisible) { const recovery = isRecoveryOperation(update.operation) const returningStable = update.operation === 'return-stable' const checkingProgress = update.operation === 'check' const target = recovery ? rollbackPlan?.previous?.dsh ?? rollbackPlan?.target?.dsh : experimental ? next.upstream?.version ?? next.supported?.dsh : next.supported?.dsh elements['progress-title'].textContent = checkingProgress ? t('statusChecking') : target ? t(returningStable ? 'returnStableToTarget' : recovery ? 'rollbackToTarget' : 'updateToTarget', { target: String(target) }) : t(returningStable ? 'returnStableProgress' : recovery ? 'rollbackProgress' : 'updateProgress') elements['progress-value'].textContent = `${String(progress)}%` connectProgressLogs(update) } else { closeProgressLogs() } const result = progressVisible || failedDismissed ? '' : update.error ? localizedError(update.error) : update.outcome ? updateOutcome(update.outcome) : '' elements['update-result'].textContent = result elements['update-result'].hidden = result === '' elements['metadata-notice'].hidden = !update.metadataUnavailable const upstreamCheckFailed = update.remoteCheckSource === 'upstream' const hasPreviousCheckResult = upstreamCheckFailed ? Boolean(update.upstream?.version) : Boolean(update.checkedAt) elements['remote-check-notice'].textContent = t(upstreamCheckFailed ? (hasPreviousCheckResult ? 'upstreamCheckFailed' : 'upstreamCheckFailedNoResult') : (hasPreviousCheckResult ? 'remoteCheckFailed' : 'remoteCheckFailedNoResult')) elements['remote-check-notice'].hidden = !update.remoteCheckError const holds = [...new Map([ ...(next.holds ?? []), ...(next.experimentalBlocked ? [next.experimentalBlocked] : []), ].map(hold => [hold.id, hold])).values()] renderHolds(holds, busy) const notices = [] if (next.aheadOfStable) notices.push(t('aheadOfStable')) if (next.experimentalBlocked) notices.push(t('experimentalBlocked')) elements.notice.textContent = notices.join(' ') elements.notice.hidden = notices.length === 0 const automatic = next.automaticCheck ?? { enabled: true, intervalSeconds: 21_600, notificationsEnabled: true } elements['automatic-enabled'].checked = automatic.enabled elements['automatic-enabled'].disabled = acting elements['automatic-enabled-label'].textContent = automatic.enabled ? t('enabled') : t('disabled') elements['automatic-interval'].value = String(automatic.intervalSeconds) elements['automatic-interval'].disabled = acting || !automatic.enabled elements['notifications-enabled'].checked = automatic.notificationsEnabled elements['notifications-enabled'].disabled = acting || !automatic.enabled const dshRunning = restart.state === 'running' const dshStopped = ['stopped', 'failed'].includes(restart.state) elements['start-dsh'].disabled = busy || !dshStopped elements['stop-dsh'].disabled = busy || !dshRunning elements['restart-dsh'].disabled = busy || !dshRunning elements['restart-state'].hidden = !restartVisible elements['restart-state'].textContent = restart.state === 'starting' ? t('starting') : restart.state === 'stopping' ? t('stopping') : restart.state === 'stopped' ? t('stopped') : restart.state === 'restarting' ? t('restarting') : restart.state === 'running' ? t('lifecycleRunning') : restart.state === 'failed' ? `${t('lifecycleFailed')}: ${localizedError(restart.error ?? '')}` : '' elements['runtime-reset'].disabled = busy || next.current === null || next.current === undefined elements['confirm-runtime-reset'].disabled = busy || next.current === null || next.current === undefined elements['runtime-reset'].textContent = runtimeReset.status === 'resetting' ? t('runtimeResetting') : t(runtimeResetExpanded ? 'cancelRuntimeReset' : 'runtimeReset') const resetActive = runtimeReset.status === 'resetting' const resetPhase = RUNTIME_RESET_PHASES[next.operation] ?? { progress: 5, label: 'runtimeResetting' } runtimeResetProgress = resetActive ? Math.max(runtimeResetProgress, resetPhase.progress) : 0 elements['runtime-reset-progress'].hidden = !resetActive elements['runtime-reset-state'].textContent = t(resetPhase.label) elements['runtime-reset-progress-value'].value = `${String(runtimeResetProgress)}%` elements['runtime-reset-progress-value'].textContent = `${String(runtimeResetProgress)}%` elements['runtime-reset-progress-track'].setAttribute('aria-valuenow', String(runtimeResetProgress)) elements['runtime-reset-progress-bar'].style.width = `${String(runtimeResetProgress)}%` elements['runtime-reset-result'].hidden = resetActive || !runtimeResetVisible elements['runtime-reset-result'].textContent = runtimeReset.status === 'success' ? t('runtimeResetComplete') : runtimeReset.status === 'failed' ? `${t('runtimeResetFailed')}: ${localizedError(runtimeReset.error ?? '')}` : '' elements['plugin-operation'].hidden = !pluginOperationVisible || pluginOperation.status !== 'failed' elements['plugin-operation'].textContent = pluginOperation.status === 'failed' ? localizedError(pluginOperation.error ?? '') : '' const dshFailed = (next?.recoveryMode !== null && next?.recoveryMode !== undefined) || next?.dshLifecycle?.state === 'failed' const dshUnavailable = dshFailed || next?.dshLifecycle?.state === 'stopped' for (const [prefix, detailKey] of [ ['system-plugin', 'systemPluginRecoveryDetail'], ['system-skill', 'systemSkillRecoveryDetail'], ['user-skill', 'userSkillRecoveryDetail'], ]) { elements[`${prefix}-recovery`].hidden = !dshFailed elements[`${prefix}-recovery-detail`].textContent = t(detailKey) } const systemPluginRestartRequired = systemPluginDraft.size > 0 || (!dshUnavailable && plugins.some(plugin => plugin.pendingRestart)) || systemPluginSubmitting || systemPluginProgress !== undefined elements['plugin-restart-required'].hidden = !systemPluginRestartRequired elements['system-plugin-draft-summary'].textContent = systemPluginSummary() if (!systemPluginRestartRequired) systemPluginProgress = undefined const pluginBusy = busy || discardingPluginDraft elements['cancel-system-plugin-changes'].disabled = pluginBusy || systemPluginSubmitting elements['apply-system-plugin-changes'].disabled = pluginBusy || systemPluginSubmitting elements['apply-system-plugin-changes'].textContent = t(dshUnavailable ? 'applyUserPluginChangesOffline' : 'applyUserPluginChanges') const skillOperationVisible = operationResultVisible(skillOperation, 'running') elements['skill-operation'].hidden = !skillOperationVisible || skillOperation.status !== 'failed' elements['skill-operation'].textContent = skillOperation.status === 'failed' ? localizedError(skillOperation.error ?? '') : '' const userSkillOperationVisible = operationResultVisible(userSkillOperation, 'running') elements['user-skill-operation'].hidden = !userSkillOperationVisible elements['user-skill-operation'].textContent = userSkillOperation.status === 'running' ? t('userSkillActionWorking') : userSkillOperation.status === 'failed' ? `${t('userSkillActionFailed')}: ${localizedError(userSkillOperation.error ?? '')}` : t('userSkillActionComplete') if (inventoriesLoaded.plugins) renderBundledPlugins(plugins, pluginBusy) if (inventoriesLoaded.systemSkills) renderSystemSkills(systemSkills, busy) if (inventoriesLoaded.userSkills) renderUserSkills(busy) if (inventoriesLoaded.userPlugins) renderUserPlugins(busy) restoreScrollablePositions(resourcePanelScrollPositions) } const INVENTORY_LOADERS = Object.freeze({ plugins: Object.freeze({ path: 'bundled-plugins', apply: value => { plugins = value.plugins ?? [] } }), systemSkills: Object.freeze({ path: 'system-skills', apply: value => { systemSkills = value.skills ?? [] } }), userSkills: Object.freeze({ path: 'user-skills', apply: value => { userSkillInventory = value } }), userPlugins: Object.freeze({ path: 'user-plugins', apply: value => { userPluginInventory = value } }), }) function loadInventory(key) { inventoryLoadRevisions[key] += 1 if (inventoryLoads[key] !== undefined) return inventoryLoads[key] const loader = INVENTORY_LOADERS[key] inventoryLoads[key] = (async () => { let loadedRevision do { loadedRevision = inventoryLoadRevisions[key] try { loader.apply(await api(loader.path)) inventoriesLoaded[key] = true if (status !== undefined) render(status) } catch { // Bootstrap-backed inventories can lag the Management service during startup. } } while (loadedRevision !== inventoryLoadRevisions[key]) })().finally(() => { inventoryLoads[key] = undefined }) return inventoryLoads[key] } async function refreshInventory(key) { if (inventoryLoads[key] !== undefined) await inventoryLoads[key] await loadInventory(key) } function inventoryKeyForTab(tab = tabButtons.find(button => button.getAttribute('aria-selected') === 'true')?.dataset.tab) { return { plugins: 'plugins', skills: 'systemSkills', 'user-skills': 'userSkills', 'user-plugins': 'userPlugins' }[tab] } function renderInventory(key) { const busy = runtimeBusy() if (key === 'plugins') renderBundledPlugins(plugins, busy || discardingPluginDraft) else if (key === 'systemSkills') renderSystemSkills(systemSkills, busy) else if (key === 'userSkills') renderUserSkills(busy) else renderUserPlugins(busy) } function loadStatus() { statusLoadRevision += 1 if (statusLoad !== undefined) return statusLoad statusLoad = (async () => { let next let loadedRevision do { loadedRevision = statusLoadRevision try { next = await api('status') render(next) clearError() setConnection('online') } catch (error) { showError(error) setConnection('offline') next = undefined } } while (loadedRevision !== statusLoadRevision) return next })().finally(() => { statusLoad = undefined }) return statusLoad } async function discardSystemPluginDraft() { if (window.sessionStorage.getItem(PLUGIN_DRAFT_KEY) !== '1') return discardingPluginDraft = true if (status !== undefined) render(status) try { for (let attempt = 0; attempt < 50; attempt += 1) { try { await api('bundled-plugins/discard', { method: 'POST' }) window.sessionStorage.removeItem(PLUGIN_DRAFT_KEY) await Promise.all([loadStatus(), refreshInventory('plugins')]) return } catch { await new Promise(resolve => window.setTimeout(resolve, 100)) } } } finally { discardingPluginDraft = false if (status !== undefined) render(status) } } async function act(path, options) { acting = true clearError() try { const result = await api(path, options) if (result?.taskId) visibleOperationTasks.add(result.taskId) await loadStatus() return true } catch (error) { showError(error) return false } finally { acting = false if (status !== undefined) render(status) } } async function waitForManagementTask(taskId, operationKey) { let lastError for (let attempt = 0; attempt < 2_400; attempt += 1) { try { const operation = operationKey === 'systemPluginOperation' ? await api(`bundled-plugins/task/${taskId}`) : (await api('status'))?.[operationKey] setConnection('online') if (operation?.taskId === taskId && operation.status !== 'running') return operation lastError = undefined } catch (error) { lastError = error } await new Promise(resolve => window.setTimeout(resolve, 250)) } throw lastError ?? new Error('Management task timed out') } async function cancelSystemPluginDraft() { if (systemPluginSubmitting) return systemPluginProgress = undefined systemPluginApplyingDraft.clear() systemPluginDraft.clear() if (plugins.some(plugin => plugin.pendingRestart)) { await act('bundled-plugins/discard', { method: 'POST' }) await refreshInventory('plugins') } window.sessionStorage.removeItem(PLUGIN_DRAFT_KEY) if (status !== undefined) render(status) } async function applySystemPluginDraft() { if (systemPluginSubmitting) return if (systemPluginDraft.size === 0) { if (plugins.some(plugin => plugin.pendingRestart)) await act('restart-dsh', { method: 'POST' }) return } systemPluginSubmitting = true systemPluginApplyingDraft.clear() for (const [id, action] of systemPluginDraft) systemPluginApplyingDraft.set(id, action) acting = true clearError() if (status !== undefined) render(status) let changed = false let strategy = 'restart' try { const changes = [...systemPluginDraft] for (const [index, [id, action]] of changes.entries()) { systemPluginProgress = { phase: 'applying', id, action, current: index + 1, total: changes.length } if (status !== undefined) render(status) const plugin = plugins.find(item => item.id === id) if (plugin === undefined) throw new Error(`System Plugin ${id} is no longer available`) const path = plugin.protected ? 'bundled-plugins/recovery-action' : 'bundled-plugins/action' const task = await api(path, { method: 'POST', body: { id, action } }) changed = true window.sessionStorage.setItem(PLUGIN_DRAFT_KEY, '1') visibleOperationTasks.add(task.taskId) const operation = await waitForManagementTask(task.taskId, 'systemPluginOperation') if (operation.status !== 'success') throw new Error(operation.error ?? 'System Plugin operation failed') if (operation.strategy === 'next-start') strategy = 'next-start' } systemPluginDraft.clear() if (strategy === 'next-start') { systemPluginProgress = undefined systemPluginApplyingDraft.clear() window.sessionStorage.removeItem(PLUGIN_DRAFT_KEY) await loadStatus() return } const restart = await api('restart-dsh', { method: 'POST' }) systemPluginProgress = { phase: 'restarting', total: changes.length, taskId: restart.taskId } if (status !== undefined) render(status) visibleOperationTasks.add(restart.taskId) window.sessionStorage.removeItem(PLUGIN_DRAFT_KEY) await loadStatus() } catch (error) { systemPluginProgress = undefined if (changed) await api('bundled-plugins/discard', { method: 'POST' }).catch(() => {}) window.sessionStorage.removeItem(PLUGIN_DRAFT_KEY) showError(error) } finally { await refreshInventory('plugins') systemPluginSubmitting = false acting = false if (status !== undefined) render(status) } } async function runSkillTask(path, body, operationKey) { if (acting) return false acting = true clearError() if (status !== undefined) render(status) try { const task = await api(path, { method: 'POST', body }) visibleOperationTasks.add(task.taskId) const operation = await waitForManagementTask(task.taskId, operationKey) if (operation.status !== 'success') throw new Error(operation.error ?? 'Skill operation failed') return true } catch (error) { showError(error) return false } finally { await refreshInventory(operationKey === 'systemSkillOperation' ? 'systemSkills' : 'userSkills') await loadStatus() acting = false if (status !== undefined) render(status) } } async function waitForUserPluginTask(taskId) { let lastError for (let attempt = 0; attempt < 2_400; attempt += 1) { try { const task = await api(`user-plugins/task/${taskId}`) if (task.status !== 'running') return task lastError = undefined } catch (error) { lastError = error } await new Promise(resolve => window.setTimeout(resolve, 250)) } throw lastError ?? new Error('User Plugin task timed out') } async function applyUserPluginDraft() { if (userPluginSubmitting) return if (userPluginDraft.size === 0) { if (userPluginInventory.restartRequired === true) await act('restart-dsh', { method: 'POST' }) return } userPluginSubmitting = true userPluginApplyingDraft.clear() for (const [name, action] of userPluginDraft) userPluginApplyingDraft.set(name, action) userPluginFeedback = null clearError() renderUserPlugins(runtimeBusy()) try { const task = await api('user-plugins/apply', { method: 'POST', body: { profile: 'web', revision: userPluginInventory.revision, actions: [...userPluginDraft].map(([name, action]) => ({ name, action })), }, }) visibleOperationTasks.add(task.taskId) userPluginDraft.clear() await loadStatus() await waitForUserPluginTask(task.taskId) await loadStatus() } catch (error) { if (error.statusCode === 409) { userPluginDraft.clear() await loadStatus() userPluginFeedback = t('userPluginRevisionConflict') } else { userPluginFeedback = `${t('userPluginApplyFailed')}: ${localizedError(error)}` showError(error) } } finally { await refreshInventory('userPlugins') userPluginApplyingDraft.clear() userPluginSubmitting = false renderUserPlugins(runtimeBusy()) } } async function checkUpdates(source = 'manual') { checking = true clearError() if (status !== undefined) render(status) try { await api('check', { method: 'POST', body: { source } }) await loadStatus() return true } catch (error) { try { await loadStatus() } catch { showError(error) } return false } finally { checking = false if (status !== undefined) render(status) } } async function saveAutomaticCheck(change) { const current = status?.automaticCheck ?? { enabled: true, intervalSeconds: 21_600, notificationsEnabled: true } await act('automatic-check', { method: 'PUT', body: { ...current, ...change } }) } function storageValue(key) { try { return window.localStorage.getItem(key) } catch { return null } } function writeStorage(key, value) { try { if (value === null) window.localStorage.removeItem(key) else window.localStorage.setItem(key, value) } catch {} } function logLevel(entry) { if (entry?.stream === 'stderr') { const message = String(entry?.message ?? '').trim() const lines = message.split('\n') if (lines.some(line => /^(?:\s*at\s+|.*\b(?:error|fatal|failed|failure|exception|panic|unhandled)\b)|错误|失败|异常|致命/iu.test(line))) return 'error' if (lines.some(line => /\b(?:warn|warning|deprecated|deprecation)\b|警告|已弃用/iu.test(line))) return 'warning' return 'info' } if (['debug', 'info', 'warning', 'error'].includes(entry?.level)) return entry.level return /^\s*(warn(?:ing)?)[\s:]/i.test(entry?.message ?? '') ? 'warning' : 'info' } function logMessageSummary(message) { const lines = String(message ?? '').split('\n').map(line => line.trim()).filter(Boolean) return lines.find(line => /^(?:AggregateError|Error|EvalError|RangeError|ReferenceError|SyntaxError|TypeError|URIError):/u.test(line)) ?? lines[0] ?? '' } function isJsonFragment(message) { return /^(?:[{}\[\]],?|"(?:[^"\\]|\\.)+"\s*:\s*.*)$/.test(message.trim()) } function compactLogEntries(entries) { const compacted = [] for (let index = 0; index < entries.length; index += 1) { const first = entries[index] const opening = first.value.message?.trim() if (opening !== '{' && opening !== '[') { if (!isJsonFragment(first.value.message ?? '')) compacted.push(first) continue } const lines = [first.value.message] const startedAt = Date.parse(first.value.timestamp) let merged = false for (let end = index + 1; end < entries.length; end += 1) { const next = entries[end] if (next.value.source !== first.value.source || next.value.stream !== first.value.stream || logLevel(next.value) !== logLevel(first.value) || Date.parse(next.value.timestamp) - startedAt > 2_000) break lines.push(next.value.message) try { const value = JSON.parse(lines.join('\n')) compacted.push({ identity: entries.slice(index, end + 1).map(item => item.identity).join('|'), value: { ...first.value, message: JSON.stringify(value) } }) index = end merged = true break } catch {} } if (!merged && !isJsonFragment(first.value.message)) compacted.push(first) } return compacted } function limitProcessedLogEntries(entries, limit) { return compactLogEntries(entries).slice(-limit) } function updateLogSources(entries) { const selected = elements['log-source'].value const values = [...new Set([ ...logModuleCatalog, ...entries.map(item => item.value.source).filter(Boolean), ])].sort() elements['log-source'].replaceChildren() const all = document.createElement('option') all.value = 'all' all.textContent = t('allSources') elements['log-source'].append(all) for (const value of values) { const option = document.createElement('option') option.value = value option.textContent = value elements['log-source'].append(option) } elements['log-source'].value = values.includes(selected) ? selected : 'all' } function filteredRawLogs() { const query = elements['log-search'].value.trim().toLocaleLowerCase(locale === 'zh' ? 'zh-CN' : 'en-US') const source = elements['log-source'].value const level = elements['log-level'].value return logEntries.slice(-logDisplayLimit).map(item => item.value).filter(entry => (source === 'all' || entry.source === source) && (level === 'all' || logLevel(entry) === level) && (query === '' || JSON.stringify(entry).toLocaleLowerCase().includes(query))) } function downloadLogJsonl(entries) { if (entries.length === 0) return const content = `${entries.map(entry => JSON.stringify(entry)).join('\n')}\n` const url = URL.createObjectURL(new Blob([content], { type: 'application/x-ndjson;charset=utf-8' })) const link = document.createElement('a') link.href = url link.download = `dsh-platform-logs-${new Date().toISOString().replace(/[:.]/gu, '-')}.jsonl` link.click() window.setTimeout(() => URL.revokeObjectURL(url), 0) } function renderLogs() { const entries = limitProcessedLogEntries(logEntries, logDisplayLimit) updateLogSources(entries) const query = elements['log-search'].value.trim().toLocaleLowerCase(locale === 'zh' ? 'zh-CN' : 'en-US') const source = elements['log-source'].value const level = elements['log-level'].value const filtered = entries.filter(item => (source === 'all' || item.value.source === source) && (level === 'all' || logLevel(item.value) === level) && (query === '' || JSON.stringify(item.value).toLocaleLowerCase().includes(query))) elements['export-logs'].disabled = filteredRawLogs().length === 0 elements['log-summary'].textContent = t('logCount', { shown: filtered.length, total: logDisplayLimit }) elements['log-list'].replaceChildren() elements['log-list'].hidden = filtered.length === 0 elements['log-resize-frame'].hidden = filtered.length === 0 elements['empty-logs'].hidden = filtered.length !== 0 elements['empty-logs'].textContent = entries.length === 0 ? t('noLogs') : t('noMatchingLogs') for (const item of filtered) { const entry = item.value const levelValue = logLevel(entry) const article = document.createElement('article') article.className = 'log-entry' const meta = document.createElement('div') meta.className = 'log-meta' const levelLabel = document.createElement('strong') levelLabel.className = `log-level ${levelValue}` levelLabel.textContent = t(`level${levelValue[0].toUpperCase()}${levelValue.slice(1)}`) const sourceLabel = document.createElement('span') sourceLabel.className = 'log-source' sourceLabel.textContent = display(entry.source) const time = document.createElement('time') time.dateTime = entry.timestamp time.textContent = localTime(entry.timestamp) const message = document.createElement('pre') message.textContent = display(logMessageSummary(entry.message)) const messageRow = document.createElement('div') messageRow.className = 'log-message-row' const details = document.createElement('pre') details.className = 'log-details' details.textContent = JSON.stringify(entry, null, 2) const expanded = expandedLogIdentities.has(item.identity) details.hidden = !expanded article.tabIndex = 0 article.setAttribute('role', 'button') article.setAttribute('aria-expanded', String(expanded)) const toggle = () => { const next = !expandedLogIdentities.has(item.identity) if (next) expandedLogIdentities.add(item.identity) else expandedLogIdentities.delete(item.identity) article.setAttribute('aria-expanded', String(next)) details.hidden = !next } article.addEventListener('click', toggle) article.addEventListener('keydown', event => { if (!['Enter', ' '].includes(event.key)) return event.preventDefault() toggle() }) const chevron = document.createElement('span') chevron.className = 'log-chevron' chevron.setAttribute('aria-hidden', 'true') meta.append(levelLabel, sourceLabel, time) messageRow.append(message, chevron) article.append(meta, messageRow, details) elements['log-list'].append(article) } if (autoScroll) elements['log-list'].scrollTop = elements['log-list'].scrollHeight } function scheduleLogRender() { if (logRenderFrame !== undefined) return logRenderFrame = window.requestAnimationFrame(() => { logRenderFrame = undefined renderLogs() }) } function appendLog(entry) { const identity = JSON.stringify(entry) const timestamp = Date.parse(entry.timestamp) if ((logClearCutoff !== null && Number.isFinite(timestamp) && timestamp <= Date.parse(logClearCutoff)) || logIdentities.has(identity)) return logEntries.push({ identity, value: entry }) logIdentities.add(identity) if (logEntries.length > LOG_STREAM_LIMIT) { for (const removed of logEntries.splice(0, logEntries.length - LOG_STREAM_LIMIT)) { logIdentities.delete(removed.identity) expandedLogIdentities.delete(removed.identity) } } scheduleLogRender() } function terminalTheme() { const dark = document.documentElement.dataset.theme === 'dark' || (document.documentElement.dataset.theme === undefined && window.matchMedia('(prefers-color-scheme: dark)').matches) return dark ? { background: '#18181a', foreground: '#f0f0f1', cursor: '#f0f0f1', selectionBackground: '#4b5f80', black: '#18181a', brightBlack: '#85858b' } : { background: '#ffffff', foreground: '#202124', cursor: '#202124', selectionBackground: '#c9d9f4', black: '#202124', brightBlack: '#6f7177' } } function setTerminalStatus(key, state, values = {}) { terminalSessionState = state elements['terminal-status'].dataset.state = state elements['terminal-status'].querySelector('strong').textContent = t(key, values) const active = terminalSessionId !== undefined elements['new-terminal'].disabled = active || ['loading', 'starting'].includes(state) elements['close-terminal'].disabled = !active elements['terminal-frame'].classList.toggle('active', active) elements['terminal-placeholder'].hidden = active } function setTerminalPlaceholder(key, loading = false) { elements['terminal-placeholder-label'].textContent = t(key) elements['terminal-loader'].hidden = !loading } function loadTerminalStyles() { if (terminalStyleLoad !== undefined) return terminalStyleLoad const stylesheet = document.createElement('link') stylesheet.rel = 'stylesheet' stylesheet.href = './vendor/xterm.css' terminalStyleLoad = new Promise((resolve, reject) => { stylesheet.addEventListener('load', resolve, { once: true }) stylesheet.addEventListener('error', () => { stylesheet.remove() terminalStyleLoad = undefined reject(new Error('terminal stylesheet failed to load')) }, { once: true }) document.head.append(stylesheet) }) return terminalStyleLoad } async function loadTerminalRuntime() { if (terminalRuntime !== undefined) return terminalRuntime if (terminalRuntimeLoad === undefined) { setTerminalStatus('terminalLoading', 'loading') setTerminalPlaceholder('terminalLoading', true) terminalRuntimeLoad = Promise.all([ import('./vendor/xterm.mjs'), import('./vendor/addon-fit.mjs'), loadTerminalStyles(), ]).then(([xterm, fit]) => Object.freeze({ Terminal: xterm.Terminal, FitAddon: fit.FitAddon })) } try { terminalRuntime = await terminalRuntimeLoad return terminalRuntime } catch (error) { terminalRuntimeLoad = undefined setTerminalStatus('terminalLoadFailed', 'failed') setTerminalPlaceholder('terminalLoadFailed') throw error } } function saveTerminalSession(value) { terminalSessionId = value try { if (value === undefined) window.sessionStorage.removeItem(TERMINAL_SESSION_KEY) else window.sessionStorage.setItem(TERMINAL_SESSION_KEY, value) } catch {} } function storedTerminalSession() { try { return window.sessionStorage.getItem(TERMINAL_SESSION_KEY) ?? undefined } catch { return undefined } } function terminalWebSocketUrl(sessionId) { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' return `${protocol}//${window.location.host}${API}/terminal/sessions/${sessionId}/stream` } function terminalDimensions() { return { cols: Math.max(2, Math.min(500, terminalEmulator?.cols ?? 80)), rows: Math.max(1, Math.min(200, terminalEmulator?.rows ?? 24)), } } function sendTerminalResize() { if (terminalSocket?.readyState !== WebSocket.OPEN) return terminalSocket.send(JSON.stringify({ type: 'resize', ...terminalDimensions() })) } function fitTerminal() { if (terminalEmulator === undefined || elements['panel-terminal'].hidden) return window.cancelAnimationFrame(terminalResizeFrame) terminalResizeFrame = window.requestAnimationFrame(() => { try { terminalFit.fit() sendTerminalResize() } catch {} }) } async function initializeTerminal() { if (terminalEmulator !== undefined) return const { Terminal, FitAddon } = await loadTerminalRuntime() terminalFit = new FitAddon() terminalEmulator = new Terminal({ cursorBlink: true, cursorStyle: 'block', cursorInactiveStyle: 'outline', fontFamily: 'ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace', fontSize: 13, lineHeight: 1.25, scrollback: 5_000, theme: terminalTheme(), }) terminalEmulator.loadAddon(terminalFit) terminalEmulator.open(elements['terminal-screen']) terminalEmulator.onData(data => { if (terminalSocket?.readyState === WebSocket.OPEN && terminalSessionState === 'connected') { terminalSocket.send(JSON.stringify({ type: 'input', data })) } }) terminalResizeObserver = new ResizeObserver(fitTerminal) terminalResizeObserver.observe(elements['terminal-frame']) const colorScheme = window.matchMedia('(prefers-color-scheme: dark)') colorScheme.addEventListener('change', () => { if (document.documentElement.dataset.theme === undefined) terminalEmulator.options.theme = terminalTheme() }) setTerminalPlaceholder('terminalPlaceholder') fitTerminal() } function clearTerminalReconnect() { window.clearTimeout(terminalReconnectTimer) terminalReconnectTimer = undefined terminalReconnectDeadline = undefined } function forgetTerminalSession(statusKey = 'terminalIdle', state = 'idle') { clearTerminalReconnect() terminalSocket?.close() terminalSocket = undefined saveTerminalSession(undefined) terminalEmulator?.reset() setTerminalStatus(statusKey, state) } function connectTerminal({ reconnect = false } = {}) { if (terminalSessionId === undefined || terminalLeaving) return clearTimeout(terminalReconnectTimer) setTerminalStatus(reconnect ? 'terminalReconnecting' : 'terminalConnecting', reconnect ? 'reconnecting' : 'connecting') terminalEmulator.reset() const sessionId = terminalSessionId const socket = new WebSocket(terminalWebSocketUrl(sessionId)) terminalSocket = socket socket.addEventListener('open', () => { if (terminalSocket !== socket || terminalSessionId !== sessionId) return socket.close() terminalReconnectDeadline = undefined setTerminalStatus('terminalConnected', 'connected') sendTerminalResize() terminalEmulator.focus() }) socket.addEventListener('message', event => { if (terminalSocket !== socket) return try { const message = JSON.parse(event.data) if (message.type === 'output' && typeof message.data === 'string') terminalEmulator.write(message.data) else if (message.type === 'exit') { const result = message.code === null || message.code === undefined ? `signal ${display(message.signal)}` : String(message.code) setTerminalStatus('terminalExited', 'exited', { status: result }) } } catch { setTerminalStatus('terminalFailed', 'failed') } }) socket.addEventListener('close', () => { if (terminalSocket !== socket) return terminalSocket = undefined if (terminalLeaving || terminalSessionId !== sessionId || terminalSessionState === 'exited') return terminalReconnectDeadline ??= Date.now() + 30_000 if (Date.now() >= terminalReconnectDeadline) { forgetTerminalSession('terminalFailed', 'failed') return } setTerminalStatus('terminalReconnecting', 'reconnecting') terminalReconnectTimer = window.setTimeout(() => connectTerminal({ reconnect: true }), 1_000) }) socket.addEventListener('error', () => { if (terminalSocket === socket) setTerminalStatus('terminalReconnecting', 'reconnecting') }) } function restoreTerminalSession() { if (terminalRestored) return Promise.resolve() if (terminalRestore !== undefined) return terminalRestore terminalRestore = (async () => { try { await initializeTerminal() } catch { return } terminalRestored = true const sessionId = storedTerminalSession() if (sessionId === undefined) return setTerminalStatus('terminalIdle', 'idle') saveTerminalSession(sessionId) try { await api(`terminal/sessions/${sessionId}`) connectTerminal() } catch (error) { if (error.statusCode === 404) forgetTerminalSession() else setTerminalStatus('terminalFailed', 'failed') } })().finally(() => { terminalRestore = undefined }) return terminalRestore } async function createTerminalSession() { try { await initializeTerminal() setTerminalStatus('terminalStarting', 'starting') fitTerminal() const session = await api('terminal/sessions', { method: 'POST', body: terminalDimensions() }) saveTerminalSession(session.sessionId) connectTerminal() } catch (error) { if (terminalEmulator !== undefined) { forgetTerminalSession('terminalFailed', 'failed') showError(error) } } } async function closeTerminalSession() { const sessionId = terminalSessionId if (sessionId === undefined) return elements['close-terminal'].disabled = true try { await api(`terminal/sessions/${sessionId}`, { method: 'DELETE' }) } catch (error) { if (error.statusCode !== 404) showError(error) } forgetTerminalSession('terminalClosed', 'closed') } function fileOperationMessage(value, failed = false) { window.clearTimeout(fileOperationTimer) fileOperationTimer = undefined elements['file-operation'].hidden = value === '' elements['file-operation'].textContent = value elements['file-operation'].classList.toggle('failed', failed) if (value !== '' && !failed) { fileOperationTimer = window.setTimeout(() => fileOperationMessage(''), 3_000) } } function finishTextInput(value) { if (textInputResolve === undefined) return const resolve = textInputResolve textInputResolve = undefined textInputValidate = undefined elements['text-input-dialog'].close() resolve(value) } function requestTextInput({ title, detail, value = '', validate = next => next.trim() === '' ? t('invalidFileName') : null }) { if (textInputResolve !== undefined) return Promise.resolve(null) elements['text-input-dialog-title'].textContent = title elements['text-input-dialog-detail'].textContent = detail elements['text-input-dialog-value'].value = value elements['text-input-dialog-value'].setAttribute('aria-label', title) elements['text-input-dialog-error'].hidden = true elements['text-input-dialog-error'].textContent = '' textInputValidate = validate elements['text-input-dialog'].showModal() elements['text-input-dialog-value'].focus() elements['text-input-dialog-value'].select() return new Promise(resolve => { textInputResolve = resolve }) } function finishConfirmation(value) { if (confirmationResolve === undefined) return const resolve = confirmationResolve confirmationResolve = undefined elements['confirmation-dialog'].close() resolve(value) } function requestConfirmation({ title, detail, confirmLabel, danger = false }) { if (confirmationResolve !== undefined) return Promise.resolve(false) elements['confirmation-dialog-title'].textContent = title elements['confirmation-dialog-detail'].textContent = detail elements['confirmation-dialog-confirm'].textContent = confirmLabel elements['confirmation-dialog-confirm'].className = danger ? 'danger' : 'primary' elements['confirmation-dialog'].showModal() return new Promise(resolve => { confirmationResolve = resolve }) } function confirmDiscardChanges() { if (!fileEditorDirty) return Promise.resolve(true) return requestConfirmation({ title: t('discardChangesTitle'), detail: t('unsavedFile'), confirmLabel: t('discardChanges'), danger: true }) } async function initializeFiles() { if (filesLoaded) return scheduleFileTaskRefresh() if (fileConfigLoad === undefined) { fileConfigLoad = api('files/config').then(config => { if (typeof config?.defaultPath !== 'string' || !Array.isArray(config.shortcuts)) throw new Error('file management configuration is invalid') filePath = config.defaultPath elements['file-path'].value = filePath elements['file-shortcuts'].replaceChildren(...config.shortcuts.map(path => { const button = document.createElement('button') button.type = 'button' button.dataset.fileLocation = path button.textContent = path button.addEventListener('click', () => { void navigateFiles(path) }) return button })) return navigateFiles(filePath, { history: false }) }).catch(error => { fileConfigLoad = undefined showError(error) }) } return fileConfigLoad } function selectedFileEntries() { return fileListing.entries.filter(entry => fileSelected.has(entry.path)) } function renderFileSelection() { const count = fileSelected.size const selected = selectedFileEntries() if (fileAttributesEntry !== null && (selected.length !== 1 || selected[0].path !== fileAttributesEntry.path)) closeFileAttributes() elements['file-selection-count'].textContent = count === 0 ? t('noFilesSelected') : t('filesSelected', { count }) const busy = fileTasksActive.some(task => ['queued', 'running'].includes(task.status)) || fileUploadQueue.some(task => ['queued', 'running'].includes(task.status)) for (const id of ['file-copy', 'file-cut', 'file-rename', 'file-delete']) elements[id].disabled = count === 0 || busy elements['file-archive'].disabled = count === 0 || busy elements['file-extract'].disabled = count !== 1 || selected[0]?.type !== 'file' || busy elements['file-rename'].disabled = count !== 1 || busy elements['file-download'].disabled = count !== 1 || !['file', 'directory'].includes(selected[0]?.type) || busy elements['file-attributes'].disabled = count !== 1 || !['file', 'directory'].includes(selected[0]?.type) || busy elements['file-paste'].disabled = fileClipboard === null || busy for (const id of ['file-new', 'file-upload', 'file-upload-directory']) elements[id].disabled = busy elements['file-select-all'].checked = count > 0 && count === visibleFileEntries().length elements['file-select-all'].indeterminate = count > 0 && count !== visibleFileEntries().length } function taskLabel(task) { const operation = { archive: t('compress'), extract: t('extract'), upload: t('upload'), download: t('download'), mkdir: t('newDirectory'), copy: t('copy'), move: t('cut'), delete: t('deletePermanently'), attributes: t('attributesOperation'), }[task.operation] ?? task.operation return `${operation}: ${task.currentPath ?? task.path ?? task.destination ?? ''}` } function renderFileTasks() { const recentCutoff = Date.now() - 10_000 const localPaths = new Set(fileUploadQueue.map(task => task.path)) const backend = fileTasksActive.filter(task => !(fileUploadQueue.length > 0 && task.operation === 'mkdir') && !(task.operation === 'upload' && localPaths.has(task.path))) const visible = [...fileUploadQueue, ...backend].filter(task => ['queued', 'running'].includes(task.status) || (task.status === 'failed' && Date.parse(task.updatedAt) >= recentCutoff)) elements['file-task-state'].hidden = visible.length === 0 elements['file-task-list'].replaceChildren(...visible.map(task => { const row = document.createElement('div') row.className = 'file-task-row' const label = document.createElement('p') label.textContent = taskLabel(task) const cancel = document.createElement('button') if (['queued', 'running'].includes(task.status)) { cancel.type = 'button' cancel.className = 'compact' cancel.textContent = t('cancelOperation') cancel.addEventListener('click', () => { if (task.local === true) task.cancel() else void api(`files/tasks/${task.taskId}`, { method: 'DELETE' }).then(refreshFileTasks).catch(showError) }) } const progress = document.createElement('progress') if (Number.isSafeInteger(task.totalBytes) && task.totalBytes > 0) { progress.max = task.totalBytes progress.value = Math.min(task.totalBytes, task.processedBytes ?? 0) } const detail = document.createElement('small') detail.textContent = task.status === 'queued' ? t('queuedOperation', { position: task.queuePosition }) : task.status === 'success' ? t('operationSucceeded') : task.status === 'failed' ? `${t('operationFailedState')}: ${task.error ?? t('operationFailed')}` : task.status === 'cancelled' ? t('operationCancelled') : Number.isSafeInteger(task.totalBytes) && task.totalBytes > 0 ? `${fileSize(task.processedBytes ?? 0)} / ${fileSize(task.totalBytes)}` : t('processingOperation') row.classList.toggle('failed', task.status === 'failed') row.append(label) if (cancel.type === 'button') row.append(cancel) row.append(progress, detail) return row })) renderFileSelection() } async function refreshFileTasks() { try { const result = await api('files/tasks') fileTasksActive = result.tasks ?? [] renderFileTasks() } catch (error) { showError(error) } } function scheduleFileTaskRefresh() { window.clearInterval(fileTaskRefreshTimer) void refreshFileTasks() fileTaskRefreshTimer = window.setInterval(() => { if (!elements['panel-files'].hidden) void refreshFileTasks() }, 500) } function inferArchiveFormat(name) { const lower = name.toLocaleLowerCase() if (lower.endsWith('.tar.gz') || lower.endsWith('.tgz')) return 'tar.gz' if (lower.endsWith('.zip')) return 'zip' if (lower.endsWith('.7z')) return '7z' return null } function closeArchivePanel() { fileArchiveMode = null elements['file-archive-panel'].hidden = true } function openArchivePanel(mode) { const selected = selectedFileEntries() if (mode === 'extract') { const format = selected.length === 1 ? inferArchiveFormat(selected[0].name) : null if (format === null) return fileOperationMessage(t('unsupportedArchive'), true) elements['file-archive-format'].value = format elements['file-archive-title'].textContent = t('extractArchive') elements['file-archive-name-row'].hidden = true elements['file-archive-submit'].textContent = t('extract') elements['file-archive-detail'].textContent = selected[0].path } else { const base = selected.length === 1 ? selected[0].name.replace(/\.(tar\.gz|tgz|zip|7z)$/iu, '') : 'archive' elements['file-archive-name'].value = base elements['file-archive-title'].textContent = t('compressItems') elements['file-archive-name-row'].hidden = false elements['file-archive-submit'].textContent = t('compress') elements['file-archive-detail'].textContent = t('filesSelected', { count: selected.length }) } fileArchiveMode = mode elements['file-archive-panel'].hidden = false } async function submitArchive() { const runWithConflicts = async body => { let conflict = 'reject' for (;;) { const result = await runFileTask({ ...body, conflict }) if (result === null || result.status === 'success') return result if (result.errorCode !== 'FILE_EXISTS') { fileOperationMessage(result.error ?? t('operationFailed'), true) return result } const decision = await chooseFileConflict(body.destination, false) if (decision.choice === 'cancel' || decision.choice === 'skip') return result conflict = decision.choice } } const selected = selectedFileEntries() if (fileArchiveMode === 'extract') { const entry = selected[0] if (entry === undefined) return closeArchivePanel() const task = await runWithConflicts({ operation: 'extract', archiveFormat: elements['file-archive-format'].value, sources: [{ path: entry.path, revision: entry.revision }], destination: filePath, }) if (task?.status === 'success') { closeArchivePanel(); await navigateFiles(filePath, { history: false }); fileOperationMessage(t('operationComplete')) } return } const name = elements['file-archive-name'].value.trim() if (!fileNameIsValid(name)) return fileOperationMessage(t('invalidArchiveName'), true) const format = elements['file-archive-format'].value const extension = format === 'tar.gz' ? '.tar.gz' : `.${format}` const task = await runWithConflicts({ operation: 'archive', archiveFormat: format, sources: sourceDescriptors(), destination: fileDestination(name.endsWith(extension) ? name : `${name}${extension}`), }) if (task?.status === 'success') { closeArchivePanel(); await navigateFiles(filePath, { history: false }); fileOperationMessage(t('operationComplete')) } } const permissionInputs = [...document.querySelectorAll('[data-permission-bit]')] function syncPermissionChecks(mode) { for (const input of permissionInputs) input.checked = (mode & Number(input.dataset.permissionBit)) !== 0 } function syncModeFromPermissions() { const current = /^[0-7]{3,4}$/u.test(elements['file-attributes-mode'].value) ? Number.parseInt(elements['file-attributes-mode'].value, 8) : 0 const permissions = permissionInputs.reduce((value, input) => value + (input.checked ? Number(input.dataset.permissionBit) : 0), 0) elements['file-attributes-mode'].value = ((current & 0o7000) | permissions).toString(8).padStart(4, '0') } function closeFileAttributes() { fileAttributesEntry = null elements['file-attributes-panel'].hidden = true elements['file-attributes'].setAttribute('aria-expanded', 'false') } function openFileAttributes(entry) { fileAttributesEntry = entry elements['file-attributes-path'].textContent = entry.path elements['file-attributes-mode'].value = entry.mode.toString(8).padStart(4, '0') elements['file-attributes-user'].value = entry.user elements['file-attributes-group'].value = entry.group elements['file-attributes-recursive'].checked = false elements['file-attributes-recursive-row'].hidden = entry.type !== 'directory' syncPermissionChecks(entry.mode) elements['file-attributes-panel'].hidden = false elements['file-attributes'].setAttribute('aria-expanded', 'true') elements['file-attributes-mode'].focus() } async function applyFileAttributes() { if (fileAttributesEntry === null) return const user = elements['file-attributes-user'].value.trim() const group = elements['file-attributes-group'].value.trim() const mode = elements['file-attributes-mode'].value.trim() if (user === '' || group === '' || !/^[0-7]{3,4}$/u.test(mode)) { fileOperationMessage(t('attributesInvalid'), true) return } const task = await startFileTask({ operation: 'attributes', sources: [{ path: fileAttributesEntry.path, revision: fileAttributesEntry.revision }], attributes: { user, group, mode, recursive: fileAttributesEntry.type === 'directory' && elements['file-attributes-recursive'].checked, }, }) if (task !== undefined) closeFileAttributes() } function visibleFileEntries() { const query = elements['file-search']?.value.trim().toLocaleLowerCase(locale === 'zh' ? 'zh-CN' : 'en-US') ?? '' const hidden = elements['file-hidden']?.checked ?? true return fileListing.entries.filter(entry => (hidden || !entry.name.startsWith('.')) && (query === '' || entry.name.toLocaleLowerCase().includes(query))) } function fileTypeMark(type) { return { directory: '▣', file: '·', symlink: '↗', fifo: '│', socket: '◉', 'block-device': '◆', 'character-device': '◇' }[type] ?? '?' } function directorySizeButton(entry) { const cached = fileDirectorySizes.get(entry.path) const current = cached?.revision === entry.revision ? cached : undefined const calculate = document.createElement('button') calculate.type = 'button' calculate.className = 'file-size-action' calculate.disabled = current?.status === 'running' calculate.textContent = current?.status === 'success' ? fileSize(current.bytes) : current?.status === 'running' ? t('calculatingSize') : current?.status === 'failed' ? t('sizeCalculationFailed') : t('calculateSize') calculate.addEventListener('click', event => { event.stopPropagation(); void calculateDirectorySize(entry) }) return calculate } async function calculateDirectorySize(entry) { fileDirectorySizes.set(entry.path, { revision: entry.revision, status: 'running' }) renderFiles() try { const task = await api('files/tasks', { method: 'POST', body: { operation: 'size', path: entry.path, revision: entry.revision } }) for (;;) { const state = await api(`files/tasks/${task.taskId}`) if (state.status !== 'running') { fileDirectorySizes.set(entry.path, state.status === 'success' && state.revision === entry.revision ? { revision: entry.revision, status: 'success', bytes: state.bytes } : { revision: entry.revision, status: 'failed' }) renderFiles() return } await new Promise(resolve => setTimeout(resolve, 250)) } } catch (error) { fileDirectorySizes.set(entry.path, { revision: entry.revision, status: 'failed' }) renderFiles() showError(error) } } function renderFiles() { const values = visibleFileEntries() elements['file-list'].replaceChildren() elements['file-empty'].hidden = values.length !== 0 || fileLoading const start = fileListing.total === 0 ? 0 : filePageIndex * filePageSize + 1 const end = Math.min(fileListing.total, start + fileListing.entries.length - 1) const lastPage = Math.max(0, Math.ceil(fileListing.total / filePageSize) - 1) elements['file-page-status'].textContent = t('totalItems', { total: fileListing.total }) elements['file-page-current'].textContent = String(filePageIndex + 1) elements['file-page-jump'].max = String(lastPage + 1) if (document.activeElement !== elements['file-page-jump']) elements['file-page-jump'].value = String(filePageIndex + 1) elements['file-page-previous'].disabled = fileLoading || filePageIndex === 0 elements['file-page-next'].disabled = fileLoading || filePageIndex === lastPage elements['file-page-jump'].disabled = fileLoading elements['file-managed-warning'].hidden = !fileListing.managed for (const entry of values) { const row = document.createElement('tr') row.dataset.type = entry.type row.classList.toggle('selected', fileSelected.has(entry.path)) const checkCell = document.createElement('td') checkCell.className = 'file-check' const check = document.createElement('input') check.type = 'checkbox' check.checked = fileSelected.has(entry.path) check.setAttribute('aria-label', entry.name) check.addEventListener('change', () => { if (check.checked) fileSelected.add(entry.path); else fileSelected.delete(entry.path) renderFiles() }) checkCell.append(check) const name = document.createElement('td') const mark = document.createElement('span') mark.className = 'file-type-mark' mark.textContent = fileTypeMark(entry.type) const label = document.createElement('strong') label.textContent = entry.name const mobileMeta = document.createElement('span') mobileMeta.className = 'file-mobile-meta' const mobileOwner = document.createElement('span') mobileOwner.textContent = `${entry.user}:${entry.group}` mobileMeta.append(mobileOwner, document.createTextNode(' · '), entry.type === 'directory' ? directorySizeButton(entry) : document.createTextNode(fileSize(entry.size))) name.append(mark, label, mobileMeta) const size = document.createElement('td') if (entry.type === 'directory') size.append(directorySizeButton(entry)); else size.textContent = fileSize(entry.size) const owner = document.createElement('td') owner.textContent = `${entry.user}:${entry.group}` const modified = document.createElement('td') modified.textContent = localTime(entry.modifiedAt) const mode = document.createElement('td') mode.textContent = (entry.mode ?? 0).toString(8).padStart(3, '0') row.append(checkCell, name, size, owner, modified, mode) row.addEventListener('click', event => { if (event.target === check) return if (fileSelected.has(entry.path)) fileSelected.delete(entry.path); else fileSelected.add(entry.path) renderFiles() }) row.addEventListener('dblclick', () => { if (entry.type === 'directory') void navigateFiles(entry.path); else if (entry.type === 'file') void openFileEditor(entry) }) elements['file-list'].append(row) } renderFileSelection() renderFileCreate() } function renderFileNavigation() { elements['file-back'].disabled = fileLoading || fileHistory.length === 0 elements['file-forward'].disabled = fileLoading || fileFuture.length === 0 elements['file-up'].disabled = fileLoading || filePath === '/' elements['file-create-location'].textContent = t('createLocation', { path: filePath }) } function fileNameIsValid(value) { return typeof value === 'string' && value.trim() !== '' && value !== '.' && value !== '..' && !/[\/\u0000-\u001f\u007f]/u.test(value) && new TextEncoder().encode(value).byteLength <= 255 } function renderFileCreate() { const value = elements['file-create-name'].value const valid = fileNameIsValid(value) elements['file-create-panel'].hidden = !fileCreateExpanded elements['file-new'].setAttribute('aria-expanded', String(fileCreateExpanded)) for (const button of document.querySelectorAll('[data-file-create-kind]')) { button.setAttribute('aria-pressed', String(button.dataset.fileCreateKind === fileCreateKind)) } elements['file-create-submit'].disabled = !valid || fileActiveTask !== null elements['file-create-error'].hidden = value === '' || valid elements['file-create-error'].textContent = valid ? '' : t('invalidFileName') elements['file-create-location'].textContent = t('createLocation', { path: filePath }) } function setFileCreateExpanded(expanded) { fileCreateExpanded = expanded if (!expanded) { elements['file-create-name'].value = '' fileCreateKind = 'touch' } renderFileCreate() if (expanded) window.requestAnimationFrame(() => elements['file-create-name'].focus()) } async function createFileEntry() { const name = elements['file-create-name'].value if (!fileNameIsValid(name)) return renderFileCreate() const destination = filePath === '/' ? `/${name}` : `${filePath}/${name}` const task = await startFileTask({ operation: fileCreateKind, destination, destinationRevision: fileListing.revision, }) if (task !== undefined) setFileCreateExpanded(false) } async function navigateFiles(path, { history = true, offset = null, pageIndex = 0 } = {}) { if (fileLoading) return false fileLoading = true renderFileNavigation() clearError() try { const query = new URLSearchParams({ path, limit: String(filePageSize), sort: fileSort, order: fileOrder }) if (offset !== null) query.set('offset', String(offset)) const next = await api(`files/list?${query}`) if (history && filePath !== next.path) { fileHistory.push(filePath) fileFuture = [] } filePath = next.path elements['file-path'].value = filePath fileSelected.clear() filePageIndex = pageIndex fileListing = next filesLoaded = true renderFiles() return true } catch (error) { showError(error) return false } finally { fileLoading = false renderFileNavigation() renderFiles() } } function renderFileSort() { for (const button of document.querySelectorAll('[data-file-sort]')) { const order = button.dataset.fileSort === fileSort ? fileOrder : '' if (order === '') delete button.dataset.sortOrder else button.dataset.sortOrder = order button.closest('th').setAttribute('aria-sort', order === 'asc' ? 'ascending' : order === 'desc' ? 'descending' : 'none') } } function updateEditorLines() { const count = elements['file-editor-content'].value.split('\n').length elements['file-editor-lines'].textContent = Array.from({ length: count }, (_, index) => String(index + 1)).join('\n') elements['file-editor-lines'].scrollTop = elements['file-editor-content'].scrollTop } function renderFileEditorState() { elements['file-editor-status'].textContent = t(fileEditorSaving ? 'fileEditorSaving' : fileEditorDirty ? 'fileEditorUnsaved' : 'fileEditorSaved') elements['file-editor-status'].classList.toggle('dirty', fileEditorDirty && !fileEditorSaving) elements['file-editor-reload'].disabled = fileEditorSaving elements['file-editor-save-as'].disabled = fileEditorSaving elements['file-editor-save'].disabled = fileEditorSaving || !fileEditorDirty } async function openFileEditor(entry) { clearError() try { fileEditor = await api(`files/content?path=${encodeURIComponent(entry.path)}`) fileEditorOriginal = fileEditor.content fileEditorDirty = false elements['file-editor-path'].textContent = fileEditor.path elements['file-editor-content'].value = fileEditor.content elements['file-editor-meta'].textContent = `${fileEditor.encoding} · ${fileSize(fileEditor.size)} · ${fileEditor.newline.toUpperCase()}` updateEditorLines() renderFileEditorState() elements['file-editor-dialog'].showModal() elements['file-editor-content'].focus() } catch (error) { showError(error) } } async function saveFileEditor(saveAs = false) { if (fileEditor === null) return let path = fileEditor.path let revision = fileEditor.revision let create = false if (saveAs) { const value = await requestTextInput({ title: t('saveAsTitle'), detail: t('saveAsDetail'), value: fileEditor.path, validate: next => next.startsWith('/') && !/[\u0000-\u001f\u007f]/u.test(next) ? null : t('invalidFileName'), }) if (value === null) return path = value revision = null create = true } fileEditorSaving = true renderFileEditorState() const content = elements['file-editor-content'].value try { const saved = await api('files/content', { method: 'PUT', body: { path, content, revision, create } }) fileEditor = { ...fileEditor, ...saved, path, content } fileEditorOriginal = content fileEditorDirty = elements['file-editor-content'].value !== fileEditorOriginal elements['file-editor-path'].textContent = path fileOperationMessage(t('fileSaved')) await navigateFiles(filePath, { history: false }) } catch (error) { if (error.statusCode === 409) fileOperationMessage(t('fileRevisionChanged'), true) else showError(error) } finally { fileEditorSaving = false renderFileEditorState() } } async function closeFileEditor() { if (!await confirmDiscardChanges()) return false elements['file-editor-dialog'].close() fileEditor = null fileEditorDirty = false fileEditorSaving = false return true } async function waitFileTask(taskId, { report = true, refresh = true } = {}) { fileActiveTask = taskId elements['file-task-state'].hidden = false renderFileSelection() let result = null try { for (;;) { const task = await api(`files/tasks/${taskId}`) elements['file-task-label'].textContent = t('taskRunning', { operation: task.operation === 'attributes' ? t('attributesOperation') : task.operation }) if (!['queued', 'running'].includes(task.status)) { result = task if (report && task.status === 'success') fileOperationMessage(t('operationComplete')) else if (report) fileOperationMessage(task.errorCode === 'FILE_ATTRIBUTES_UNSUPPORTED' ? t('attributesUnsupported') : task.error ?? t('operationFailed'), true) break } await new Promise(resolve => setTimeout(resolve, 300)) } } catch (error) { showError(error) } finally { fileActiveTask = null await refreshFileTasks() if (refresh) await navigateFiles(filePath, { history: false }) } return result } async function startFileTask(body) { clearError() try { const task = await api('files/tasks', { method: 'POST', body }) await refreshFileTasks() void waitFileTask(task.taskId) return task } catch (error) { showError(error); return undefined } } function sourceDescriptors() { return selectedFileEntries().map(entry => ({ path: entry.path, revision: entry.revision })) } function fileDestination(name) { return filePath === '/' ? `/${name}` : `${filePath}/${name}` } function finishFileConflict(value) { if (fileConflictResolve === null) return const resolve = fileConflictResolve fileConflictResolve = null elements['file-conflict-dialog'].close() resolve(value) } function chooseFileConflict(path, multiple) { elements['file-conflict-path'].textContent = path elements['file-conflict-all-row'].hidden = !multiple elements['file-conflict-all'].checked = false elements['file-conflict-confirm'].disabled = true for (const input of document.querySelectorAll('input[name="file-conflict-choice"]')) input.checked = false elements['file-conflict-dialog'].showModal() return new Promise(resolve => { fileConflictResolve = resolve }) } async function runFileTask(body) { clearError() try { const task = await api('files/tasks', { method: 'POST', body }) return await waitFileTask(task.taskId, { report: false, refresh: false }) } catch (error) { showError(error) return null } } function readDroppedFile(entry) { return new Promise((resolve, reject) => entry.file(resolve, reject)) } async function readDroppedDirectory(entry) { const reader = entry.createReader() const entries = [] for (;;) { const batch = await new Promise((resolve, reject) => reader.readEntries(resolve, reject)) if (batch.length === 0) return entries entries.push(...batch) } } async function collectDroppedEntry(entry, parent = '') { const relativePath = parent === '' ? entry.name : `${parent}/${entry.name}` if (entry.isFile) return [{ file: await readDroppedFile(entry), relativePath }] if (!entry.isDirectory) return [] const children = await readDroppedDirectory(entry) const nested = await Promise.all(children.map(child => collectDroppedEntry(child, relativePath))) return [{ directory: true, relativePath }, ...nested.flat()] } async function droppedUploadItems(dataTransfer) { const entries = [...(dataTransfer.items ?? [])] .filter(item => item.kind === 'file') .map(item => item.webkitGetAsEntry?.()) .filter(Boolean) if (entries.length > 0) return (await Promise.all(entries.map(entry => collectDroppedEntry(entry)))).flat() return [...dataTransfer.files].map(file => ({ file, relativePath: file.name })) } async function uploadFiles(values) { if (fileUploadQueue.some(task => ['queued', 'running'].includes(task.status))) return const files = values.map(value => value?.directory === true || value?.file instanceof File ? value : { file: value, relativePath: value.webkitRelativePath || value.name }) const total = files.length fileUploadQueue = files.map(({ file, relativePath, directory = false }, index) => { const destination = filePath === '/' ? `/${relativePath}` : `${filePath}/${relativePath}` const task = { local: true, taskId: `browser-upload-${String(index)}`, operation: directory ? 'mkdir' : 'upload', path: destination, currentPath: destination, status: 'queued', queuePosition: index + 1, processedBytes: 0, totalBytes: file?.size ?? 0, updatedAt: new Date().toISOString(), cancelRequested: false, request: null, } task.cancel = () => { task.cancelRequested = true task.request?.abort() fileUploadQueue = fileUploadQueue.filter(value => value !== task) renderFileTasks() } return task }) renderFileTasks() let conflictForAll = null let skipped = 0 let stopped = false for (let index = 0; index < files.length; index += 1) { const { file, relativePath, directory = false } = files[index] const uploadTask = fileUploadQueue.find(task => task.taskId === `browser-upload-${String(index)}`) if (uploadTask === undefined || uploadTask.cancelRequested) continue uploadTask.status = 'running' uploadTask.queuePosition = 0 for (const [position, task] of fileUploadQueue.filter(task => task.status === 'queued').entries()) task.queuePosition = position + 1 elements['file-task-label'].textContent = t('uploadProgress', { current: index + 1, total }) renderFileTasks() const segments = relativePath.split('/') if (segments.some(segment => !fileNameIsValid(segment))) { showError(new Error(t('invalidFileName'))) stopped = true break } let parent = filePath for (const segment of directory ? segments : segments.slice(0, -1)) { parent = parent === '/' ? `/${segment}` : `${parent}/${segment}` try { const existing = await api(`files/stat?path=${encodeURIComponent(parent)}&optional=true`) if (existing !== null && existing.type !== 'directory') throw new Error(`${parent}: ${t('operationFailed')}`) if (existing !== null) continue const created = await runFileTask({ operation: 'mkdir', destination: parent }) if (created?.status !== 'success') { fileOperationMessage(created?.error ?? t('operationFailed'), true) stopped = true break } } catch (error) { showError(error) stopped = true break } } if (stopped) break if (directory) { fileUploadQueue = fileUploadQueue.filter(task => task !== uploadTask) renderFileTasks() continue } const destination = parent === '/' ? `/${segments.at(-1)}` : `${parent}/${segments.at(-1)}` let conflict = ['overwrite', 'rename'].includes(conflictForAll) ? conflictForAll : 'reject' for (;;) { elements['file-task-state'].hidden = false elements['file-task-label'].textContent = t('uploadProgress', { current: index + 1, total: files.length }) try { await new Promise((resolve, reject) => { const request = new XMLHttpRequest() uploadTask.request = request request.open('POST', `${API}/files/upload?path=${encodeURIComponent(destination)}&conflict=${conflict}`) request.setRequestHeader('x-dsh-csrf', browserCookie('dsh_management_csrf')) request.upload.onprogress = event => { uploadTask.processedBytes = event.loaded if (event.lengthComputable) uploadTask.totalBytes = event.total uploadTask.updatedAt = new Date().toISOString() renderFileTasks() } request.onload = () => { if (request.status >= 200 && request.status < 300) return resolve() const value = JSON.parse(request.responseText || '{}') const error = new Error(value.error ?? `HTTP ${String(request.status)}`) error.statusCode = request.status error.code = value.code reject(error) } request.onerror = () => reject(new Error(t('operationFailed'))) request.onabort = () => reject(Object.assign(new Error(t('operationCancelled')), { code: 'FILE_TASK_CANCELLED' })) request.send(file) }) uploadTask.request = null fileUploadQueue = fileUploadQueue.filter(task => task !== uploadTask) renderFileTasks() break } catch (error) { uploadTask.request = null if (uploadTask.cancelRequested || error.code === 'FILE_TASK_CANCELLED') break if (error.code === 'FILE_EXISTS') { if (conflictForAll === 'skip') { skipped += 1; break } const decision = await chooseFileConflict(destination, files.length > 1) if (decision.choice === 'cancel') { stopped = true; break } if (decision.applyAll) conflictForAll = decision.choice if (decision.choice === 'skip') { skipped += 1; break } conflict = decision.choice continue } showError(error) stopped = true break } } fileUploadQueue = fileUploadQueue.filter(task => task !== uploadTask) renderFileTasks() if (stopped) break } fileUploadQueue = [] elements['file-task-label'].textContent = '' renderFileTasks() await navigateFiles(filePath, { history: false }) if (!stopped) fileOperationMessage(skipped > 0 ? t('operationCompleteWithSkipped', { count: skipped }) : t('operationComplete')) } async function pasteFiles() { if (fileClipboard === null) return const clipboard = fileClipboard fileClipboard = null renderFileSelection() let conflictForAll = null let skipped = 0 let stopped = false for (const source of clipboard.sources) { let conflict = ['overwrite', 'rename'].includes(conflictForAll) ? conflictForAll : 'reject' for (;;) { const task = await runFileTask({ operation: clipboard.operation, sources: [source], destination: filePath, conflict }) if (task === null) { stopped = true; break } if (task.status === 'success') break if (task.errorCode === 'FILE_EXISTS') { if (conflictForAll === 'skip') { skipped += 1; break } const name = source.path.split('/').at(-1) const decision = await chooseFileConflict(fileDestination(name), clipboard.sources.length > 1) if (decision.choice === 'cancel') { stopped = true; break } if (decision.applyAll) conflictForAll = decision.choice if (decision.choice === 'skip') { skipped += 1; break } conflict = decision.choice continue } fileOperationMessage(task.error ?? t('operationFailed'), true) stopped = true break } if (stopped) break } await navigateFiles(filePath, { history: false }) if (!stopped) fileOperationMessage(skipped > 0 ? t('operationCompleteWithSkipped', { count: skipped }) : t('operationComplete')) } async function recursiveFileSearch() { const query = elements['file-search'].value.trim() if (query === '') return try { const task = await api('files/tasks', { method: 'POST', body: { operation: 'search', path: filePath, revision: fileListing.revision, query } }) fileActiveTask = task.taskId elements['file-task-state'].hidden = false elements['file-task-label'].textContent = t('searchRunning') for (;;) { const state = await api(`files/tasks/${task.taskId}?limit=1000`) if (state.status !== 'running') { if (state.status === 'success') { fileListing = { ...fileListing, entries: state.results, nextCursor: null, total: state.results.length } elements['file-search'].value = '' fileSelected.clear() renderFiles() } else fileOperationMessage(state.error ?? t('operationFailed'), true) break } await new Promise(resolve => setTimeout(resolve, 250)) } } catch (error) { showError(error) } finally { fileActiveTask = null; elements['file-task-state'].hidden = true } } const PROXY_TEST_STAGE_LABELS = Object.freeze({ 'proxy-address': 'proxyStageAddress', 'proxy-connect': 'proxyStageConnect', 'proxy-handshake': 'proxyStageHandshake', 'target-dns': 'proxyStageDns', 'target-tls': 'proxyStageTls', 'target-http': 'proxyStageHttp', }) const PROXY_TEST_DETAIL_LABELS = Object.freeze({ 'route-direct': 'proxyStageDetailDirect', 'proxy-addresses-resolved': 'proxyStageDetailAddress', 'proxy-connected': 'proxyStageDetailConnect', 'proxy-handshake-completed': 'proxyStageDetailHandshake', 'target-addresses-resolved': 'proxyStageDetailDns', 'target-tls-verified': 'proxyStageDetailTls', 'target-http-completed': 'proxyStageDetailHttp', }) function localizedProxyTestDetail(stage, fallback) { const key = PROXY_TEST_DETAIL_LABELS[stage.detailCode] if (key === undefined) return stage.detail ?? stage.errorCode ?? fallback const values = { ...(stage.detailData ?? {}) } if (stage.detailCode === 'target-http-completed') { const labels = { 'github-metadata': 'proxyTargetGithub', 'npm-registry': 'proxyTargetNpm' } values.results = (Array.isArray(values.results) ? values.results : []).map(result => ( `${t(labels[result?.id] ?? result?.id ?? 'unknown')} HTTP ${String(result?.status ?? '-')}` )).join(locale === 'zh' ? ';' : ', ') } return t(key, values) } function pendingProxyTestStages(proxy = {}) { return Object.keys(PROXY_TEST_STAGE_LABELS) .filter(stage => stage !== 'target-dns' || (proxy.protocol === 'socks5' && proxy.remoteDns === false)) .map(stage => ({ stage, status: 'pending' })) } function failedProxyTest(error, stages = proxyTestTask?.stages) { return { status: 'failed', stages: stages?.length > 0 ? stages : pendingProxyTestStages(), error: { detail: localizedError(error) }, } } function proxyLines(value) { return String(value ?? '').split(/\r?\n/u).map(entry => entry.trim()).filter(Boolean) } function directRuleText(configuration) { return [...new Set([ ...(configuration.noProxy?.user ?? []), ...(configuration.bypass?.additional ?? []), ])].join('\n') } function splitDirectRules(value) { const rules = proxyLines(value) return { noProxy: rules.filter(rule => !/\/\d+$/u.test(rule)), bypass: rules.filter(rule => /\/\d+$/u.test(rule)), } } function clearProxySecrets() { elements['proxy-password'].value = '' elements['proxy-password'].disabled = proxyConfiguration?.componentReady === false elements['proxy-clear-password'].setAttribute('aria-pressed', 'false') renderProxyTransportWarning() } function renderProxyTransportWarning() { const loopback = location.hostname === 'localhost' || location.hostname === '[::1]' || /^127(?:\.\d{1,3}){3}$/u.test(location.hostname) elements['proxy-transport-warning'].hidden = location.protocol === 'https:' || loopback || (elements['proxy-password'].value === '' && elements['proxy-username'].value === '') } function proxyFormDirty() { if (proxyConfiguration === undefined) return false return elements['proxy-protocol'].value !== proxyConfiguration.proxy.protocol || elements['proxy-host'].value.trim() !== proxyConfiguration.proxy.host || (elements['proxy-port'].value === '' ? null : Number(elements['proxy-port'].value)) !== proxyConfiguration.proxy.port || elements['proxy-username'].value !== (proxyConfiguration.proxy.username ?? '') || elements['proxy-password'].value !== '' || elements['proxy-clear-password'].getAttribute('aria-pressed') === 'true' || elements['proxy-remote-dns'].checked !== (proxyConfiguration.proxy.remoteDns === true) || elements['proxy-direct-rules'].value !== directRuleText(proxyConfiguration) || elements['proxy-all-proxy'].checked !== (proxyConfiguration.environment?.allProxy === 'scope-proxy') } function renderProxySaveState({ saved = false } = {}) { window.clearTimeout(proxySaveStateTimer) const indicator = elements['proxy-save-state'] if (proxyFormDirty()) { indicator.hidden = false indicator.dataset.state = 'unsaved' indicator.textContent = t('proxyUnsaved') return } if (!saved) { indicator.hidden = true indicator.textContent = '' delete indicator.dataset.state return } indicator.hidden = false indicator.dataset.state = 'saved' indicator.textContent = t('proxySavedShort') proxySaveStateTimer = window.setTimeout(() => { indicator.hidden = true indicator.textContent = '' delete indicator.dataset.state }, 3000) } function proxyCandidate({ connection = 'form', rules = 'form', configuration = proxyConfiguration } = {}) { if (proxyConfiguration === undefined) throw new Error(t('proxyComponentUnavailable')) const directRules = rules === 'form' ? splitDirectRules(elements['proxy-direct-rules'].value) : { noProxy: configuration.noProxy?.user ?? [], bypass: configuration.bypass?.additional ?? [] } const password = elements['proxy-password'].value const clearPassword = elements['proxy-clear-password'].getAttribute('aria-pressed') === 'true' const proxy = connection === 'form' ? { protocol: elements['proxy-protocol'].value, host: elements['proxy-host'].value.trim(), port: elements['proxy-port'].value === '' ? null : Number(elements['proxy-port'].value), username: elements['proxy-username'].value, passwordConfigured: configuration.proxy.passwordConfigured === true, remoteDns: elements['proxy-remote-dns'].checked, } : { ...configuration.proxy } if (connection === 'form') { if (clearPassword) proxy.clearPassword = true else if (password !== '') proxy.password = password } return { schema: 1, enabled: configuration.enabled === true, proxy, scopes: { ...configuration.scopes }, environment: { allProxy: rules === 'form' ? (elements['proxy-all-proxy'].checked ? 'scope-proxy' : null) : configuration.environment?.allProxy ?? null }, modelApi: { default: configuration.modelApi?.default ?? { proxyEnabled: false }, providers: { ...(configuration.modelApi?.providers ?? {}) }, }, noProxy: { user: directRules.noProxy }, bypass: { additional: directRules.bypass }, } } function renderProxyProviders() { const container = elements['proxy-provider-list'] container.replaceChildren() const query = elements['proxy-provider-search'].value.trim().toLocaleLowerCase() const providers = (proxyProviderInventory.providers ?? []).filter(provider => { const displayName = typeof provider.displayName === 'string' && provider.displayName.trim() !== '' ? provider.displayName : provider.id return query === '' || displayName.toLocaleLowerCase().includes(query) }) elements['proxy-provider-empty'].hidden = providers.length > 0 container.hidden = providers.length === 0 for (const provider of providers) { const row = document.createElement('div') row.className = 'proxy-provider-item' const identity = document.createElement('div') identity.className = 'proxy-provider-identity' const name = document.createElement('strong') const displayName = typeof provider.displayName === 'string' && provider.displayName.trim() !== '' ? provider.displayName : provider.id name.textContent = displayName identity.append(name) const information = provider.routingCapability === 'forced-direct' ? t('proxyProviderReasonLocal') : provider.routingCapability === 'shared-dsh' ? t('proxyProviderReasonShared') : null if (information !== null) { const info = document.createElement('button') info.type = 'button' info.className = 'proxy-provider-info' info.textContent = 'i' info.setAttribute('aria-label', t('proxyProviderInfo', { name: displayName })) info.addEventListener('click', () => { elements['proxy-provider-info-title'].textContent = displayName elements['proxy-provider-info-detail'].textContent = information elements['proxy-provider-info-dialog'].showModal() }) identity.append(info) } const policy = proxyConfiguration.modelApi?.providers?.[provider.id] ?? provider.requestedPolicy ?? { proxyEnabled: false } const controls = document.createElement('div') controls.className = 'proxy-provider-controls' if (provider.routingCapability !== 'provider') { const route = document.createElement('span') route.className = 'proxy-provider-route' route.textContent = t(provider.routingCapability === 'shared-dsh' ? 'proxyProviderShared' : 'proxyProviderDirect') controls.append(route) } const toggle = document.createElement('label') toggle.className = 'toggle' toggle.setAttribute('aria-label', displayName) const input = document.createElement('input') input.type = 'checkbox' input.dataset.providerPolicy = provider.id input.checked = policy.proxyEnabled === true input.disabled = provider.routingCapability === 'forced-direct' || proxyConfiguration.componentReady === false input.addEventListener('change', async event => { const next = event.target.checked await saveProxyImmediate(configuration => { configuration.modelApi.providers[provider.id] = { proxyEnabled: next } }) }) const track = document.createElement('span') track.setAttribute('aria-hidden', 'true') toggle.append(input, track) controls.append(toggle) row.append(identity, controls) container.append(row) } } function renderProxyCatalog() { const catalog = proxyConfiguration?.scopeCatalog const container = elements['proxy-scope-catalog'] container.replaceChildren() if (catalog?.entries === undefined) return const groups = new Map() for (const entry of catalog.entries) { if (!groups.has(entry.group)) groups.set(entry.group, []) groups.get(entry.group).push(entry) } for (const [group, entries] of groups) { const section = document.createElement('section') section.className = 'proxy-scope-group' const heading = document.createElement('h3') heading.textContent = catalog.groups?.[group]?.[locale] ?? group heading.style.gridRow = `1 / span ${entries.length}` section.append(heading) for (const entry of entries) { const row = document.createElement('div') row.className = 'proxy-scope-entry' const source = document.createElement('span') source.textContent = entry.source?.[locale] ?? entry.source?.en ?? entry.id const detail = document.createElement('button') detail.type = 'button' detail.className = 'proxy-scope-description' detail.textContent = entry.detail?.[locale] ?? entry.detail?.en ?? '' row.append(source, detail) section.append(row) } container.append(section) } const summaries = elements['proxy-scope-summaries'] summaries.replaceChildren(...(catalog.summaries ?? []).map(value => { const paragraph = document.createElement('p') paragraph.textContent = value?.[locale] ?? value?.en ?? '' return paragraph })) refreshProxyDescriptions() } function renderProxyConfiguration() { if (proxyConfiguration === undefined) return const configuration = proxyConfiguration const componentUnavailable = configuration.componentReady === false elements['proxy-enabled'].checked = configuration.enabled === true elements['proxy-enabled'].disabled = componentUnavailable elements['proxy-protocol'].value = configuration.proxy.protocol elements['proxy-host'].value = configuration.proxy.host elements['proxy-port'].value = configuration.proxy.port ?? '' elements['proxy-username'].value = configuration.proxy.username ?? '' elements['proxy-remote-dns'].checked = configuration.proxy.remoteDns === true elements['proxy-remote-dns-row'].hidden = configuration.proxy.protocol !== 'socks5' elements['proxy-clear-password'].hidden = configuration.proxy.passwordConfigured !== true for (const id of [ 'proxy-protocol', 'proxy-host', 'proxy-port', 'proxy-username', 'proxy-password', 'proxy-clear-password', 'proxy-advanced-toggle', 'proxy-direct-rules', 'proxy-system-rules', 'proxy-remote-dns', 'proxy-provider-search', 'proxy-scope-help', ]) elements[id].disabled = componentUnavailable for (const input of document.querySelectorAll('[data-proxy-scope]')) { input.checked = configuration.scopes?.[input.dataset.proxyScope] === true input.disabled = componentUnavailable } elements['proxy-direct-rules'].value = directRuleText(configuration) elements['proxy-system-rules-list'].textContent = (configuration.noProxy?.system ?? []).join('\n') elements['proxy-all-proxy'].checked = configuration.environment?.allProxy === 'scope-proxy' elements['proxy-all-proxy'].disabled = componentUnavailable elements['proxy-test'].disabled = componentUnavailable elements['proxy-save'].disabled = componentUnavailable elements['proxy-component-alert'].hidden = !componentUnavailable renderProxyProviders() renderProxyCatalog() renderProxyTransportWarning() renderProxySaveState() } function renderProxyTest(task = proxyTestTask) { proxyTestTask = task const container = elements['proxy-test-stages'] container.replaceChildren() for (const stage of task?.stages ?? []) { const item = document.createElement('li') item.className = 'proxy-test-stage' item.dataset.state = stage.status item.tabIndex = 0 item.setAttribute('role', 'button') item.setAttribute('aria-expanded', String(expandedProxyTestStages.has(stage.stage))) const marker = document.createElement('span') marker.setAttribute('aria-hidden', 'true') const content = document.createElement('span') const label = document.createElement('strong') label.textContent = t(PROXY_TEST_STAGE_LABELS[stage.stage] ?? stage.stage) const detail = document.createElement('small') const status = t(`proxyStage${stage.status[0].toUpperCase()}${stage.status.slice(1)}`) detail.textContent = localizedProxyTestDetail(stage, status) detail.hidden = !expandedProxyTestStages.has(stage.stage) const toggleDetails = () => { preserveScrollableAncestors(item, () => { if (expandedProxyTestStages.has(stage.stage)) expandedProxyTestStages.delete(stage.stage) else expandedProxyTestStages.add(stage.stage) const expanded = expandedProxyTestStages.has(stage.stage) item.setAttribute('aria-expanded', String(expanded)) detail.hidden = !expanded }) } item.addEventListener('click', toggleDetails) item.addEventListener('keydown', event => { if (event.key !== 'Enter' && event.key !== ' ') return event.preventDefault() toggleDetails() }) content.append(label, detail) const statusLabel = document.createElement('small') statusLabel.textContent = status item.append(marker, content, statusLabel) container.append(item) } const running = task?.status === 'starting' || task?.status === 'running' const componentUnavailable = proxyConfiguration?.componentReady === false elements['proxy-test'].disabled = running || componentUnavailable elements['proxy-save'].disabled = running || componentUnavailable elements['proxy-test-cancel'].hidden = task === undefined elements['proxy-test-cancel'].disabled = running && task?.taskId === undefined elements['proxy-test-cancel'].textContent = t(running ? 'cancel' : 'close') const result = elements['proxy-test-result'] result.hidden = task === undefined || running if (task !== undefined && !running) { result.textContent = task.status === 'success' ? t('proxyTestSuccess') : task.status === 'cancelled' ? t('proxyTestCancelled') : `${t('proxyTestFailed')}: ${task.error?.detail ?? task.error?.errorCode ?? ''}` } } function scheduleProxyTestPoll(taskId) { window.clearTimeout(proxyTestPollTimer) proxyTestPollTimer = window.setTimeout(async () => { try { const task = await api(`proxy/test/tasks/${taskId}`) renderProxyTest(task) if (task.status === 'running') scheduleProxyTestPoll(taskId) else clearProxySecrets() } catch (error) { renderProxyTest(failedProxyTest(error)) } }, 400) } async function loadProxy({ force = false } = {}) { if (proxyLoading !== undefined) return proxyLoading if (proxyLoaded && !force) return proxyLoading = (async () => { try { proxyConfiguration = await api('proxy') proxyLoaded = true clearProxySecrets() renderProxyConfiguration() void refreshProxyProviders() const activeTaskId = status?.proxyTestOperation?.status === 'running' ? status.proxyTestOperation.taskId : undefined if (activeTaskId !== undefined) { const task = await api(`proxy/test/tasks/${activeTaskId}`) renderProxyTest(task) scheduleProxyTestPoll(activeTaskId) } } catch (error) { showError(error) } })().finally(() => { proxyLoading = undefined }) return proxyLoading } async function refreshProxyProviders() { if (proxyProviderLoading !== undefined) return proxyProviderLoading proxyProviderLoading = api('proxy/provider-inventory').then(providers => { proxyProviderInventory = providers renderProxyProviders() }).catch(error => { if (!elements['panel-proxy'].hidden) showError(error) }).finally(() => { proxyProviderLoading = undefined }) return proxyProviderLoading } function refreshVisibleProxyProviders() { if (proxyLoaded && !elements['panel-proxy'].hidden) void refreshProxyProviders() } async function saveProxyConfiguration() { elements['proxy-save'].disabled = true try { proxyConfiguration = await api('proxy', { method: 'PUT', body: { baseRevision: proxyConfiguration.revision, value: proxyCandidate() }, }) clearProxySecrets() renderProxyConfiguration() renderProxySaveState({ saved: true }) clearError() } catch (error) { showError(error) renderProxySaveState() if (error.statusCode === 409) await loadProxy({ force: true }) } finally { elements['proxy-save'].disabled = proxyConfiguration?.componentReady === false } } function captureProxyDraft() { return { protocol: elements['proxy-protocol'].value, host: elements['proxy-host'].value, port: elements['proxy-port'].value, username: elements['proxy-username'].value, password: elements['proxy-password'].value, clearPassword: elements['proxy-clear-password'].getAttribute('aria-pressed'), remoteDns: elements['proxy-remote-dns'].checked, directRules: elements['proxy-direct-rules'].value, allProxy: elements['proxy-all-proxy'].checked, } } function restoreProxyDraft(draft) { elements['proxy-protocol'].value = draft.protocol elements['proxy-host'].value = draft.host elements['proxy-port'].value = draft.port elements['proxy-username'].value = draft.username elements['proxy-password'].value = draft.password elements['proxy-clear-password'].setAttribute('aria-pressed', draft.clearPassword) elements['proxy-password'].disabled = draft.clearPassword === 'true' || proxyConfiguration?.componentReady === false elements['proxy-remote-dns'].checked = draft.remoteDns elements['proxy-remote-dns-row'].hidden = draft.protocol !== 'socks5' elements['proxy-direct-rules'].value = draft.directRules elements['proxy-all-proxy'].checked = draft.allProxy renderProxyTransportWarning() renderProxySaveState() } function saveProxyImmediate(mutate) { proxyImmediateSave = proxyImmediateSave.then(async () => { const draft = captureProxyDraft() const next = structuredClone(proxyConfiguration) mutate(next) try { proxyConfiguration = await api('proxy', { method: 'PUT', body: { baseRevision: proxyConfiguration.revision, value: proxyCandidate({ connection: 'saved', rules: 'saved', configuration: next }) }, }) renderProxyConfiguration() restoreProxyDraft(draft) clearError() } catch (error) { showError(error) if (error.statusCode === 409) await loadProxy({ force: true }) else renderProxyConfiguration() } }) return proxyImmediateSave } async function startProxyTest() { expandedProxyTestStages.clear() proxyTestTask = { status: 'starting', stages: pendingProxyTestStages({ protocol: elements['proxy-protocol'].value, remoteDns: elements['proxy-remote-dns'].checked, }) } elements['proxy-test-dialog'].showModal() renderProxyTest(proxyTestTask) try { const started = await api('proxy/test', { method: 'POST', body: { baseRevision: proxyConfiguration.revision, value: proxyCandidate() }, }) const task = await api(`proxy/test/tasks/${started.taskId}`) renderProxyTest(task) scheduleProxyTestPoll(started.taskId) clearError() } catch (error) { renderProxyTest(failedProxyTest(error)) if (error.statusCode === 409) await loadProxy({ force: true }) } } async function selectTab(tab) { const current = tabButtons.find(button => button.getAttribute('aria-selected') === 'true')?.dataset.tab if (current === 'files' && tab !== 'files') { if (!await confirmDiscardChanges()) return false fileClipboard = null fileSelected.clear() } if (current === 'proxy' && tab !== 'proxy') clearProxySecrets() for (const button of tabButtons) { const active = button.dataset.tab === tab button.setAttribute('aria-selected', String(active)) button.tabIndex = active ? 0 : -1 elements[`panel-${button.dataset.tab}`].hidden = !active } const activeTab = tabButtons.find(button => button.dataset.tab === tab) if (activeTab !== undefined) { const tablist = activeTab.parentElement const left = activeTab.offsetLeft const right = left + activeTab.offsetWidth const visibleLeft = tablist.scrollLeft const visibleRight = visibleLeft + tablist.clientWidth if (left < visibleLeft) tablist.scrollLeft = left else if (right > visibleRight) tablist.scrollLeft = right - tablist.clientWidth } if (tab === 'maintenance') { connectLogs() } if (tab === 'maintenance' && autoScroll) { window.requestAnimationFrame(() => { elements['log-list'].scrollTop = elements['log-list'].scrollHeight }) } if (tab === 'terminal') { void restoreTerminalSession() fitTerminal() window.requestAnimationFrame(() => terminalEmulator?.focus()) } const inventoryKey = inventoryKeyForTab(tab) if (inventoryKey !== undefined) void loadInventory(inventoryKey) if (tab === 'files' && !filesLoaded) void initializeFiles() else if (tab === 'files') scheduleFileTaskRefresh() if (tab === 'proxy') { if (proxyLoaded) void refreshProxyProviders() else void loadProxy() } if (tab === 'auth-settings') void loadAuthenticationSettings() return true } function connectEvents() { eventSource?.close() eventSource = new EventSource(`${API}/events`) eventSource.addEventListener('state', event => { try { const value = JSON.parse(event.data) if (value?.fileTask?.taskId === fileActiveTask) { const task = value.fileTask elements['file-task-label'].textContent = t('taskRunning', { operation: task.operation }) } } catch {} void loadStatus() const inventoryKey = inventoryKeyForTab() if (inventoryKey !== undefined) void loadInventory(inventoryKey) }) eventSource.onopen = () => setConnection('online') eventSource.onerror = () => setConnection('connecting') } function connectLogs({ force = false } = {}) { if (force) { logSource?.close() logSource = undefined } if (logSource !== undefined) return logLastActivity = Date.now() elements['log-connection'].dataset.state = 'connecting' elements['log-connection'].querySelector('strong').textContent = t('logsConnecting') logSource = new EventSource(`${API}/logs/stream?limit=${String(LOG_STREAM_LIMIT)}`) logSource.addEventListener('sources', event => { try { const values = JSON.parse(event.data) if (!Array.isArray(values)) return logModuleCatalog = values.filter(value => typeof value === 'string' && value.length > 0).sort() renderLogs() } catch {} }) logSource.addEventListener('log', event => { logLastActivity = Date.now() try { appendLog(JSON.parse(event.data)) } catch {} }) logSource.addEventListener('heartbeat', () => { logLastActivity = Date.now() }) logSource.onopen = () => { logLastActivity = Date.now() elements['log-connection'].dataset.state = 'live' elements['log-connection'].querySelector('strong').textContent = t('logsLive') } logSource.onerror = () => { elements['log-connection'].dataset.state = 'disconnected' elements['log-connection'].querySelector('strong').textContent = t('logsDisconnected') } if (logWatchdogTimer === undefined) { logWatchdogTimer = window.setInterval(() => { if (logSource !== undefined && Date.now() - logLastActivity > 35_000) connectLogs({ force: true }) }, 5_000) } } async function refreshLogs() { elements['refresh-logs'].disabled = true try { const result = await api(`logs?limit=${String(LOG_STREAM_LIMIT)}`) for (const entry of result.entries ?? []) appendLog(entry) logEntries.sort((left, right) => left.value.timestamp.localeCompare(right.value.timestamp)) renderLogs() connectLogs({ force: true }) } catch (error) { showError(error) } finally { elements['refresh-logs'].disabled = false } } let authenticationSettings let pendingTotpChange let totpConfirming = false let totpRetryTimer const AUTHENTICATION_PASSWORD_FIELDS = Object.freeze([ 'auth-current-password', 'auth-password', 'auth-password-confirm', 'auth-additional-password', 'auth-additional-password-confirm', ]) function renderAuthenticationFormatError(id) { const input = elements[id] const error = elements[`${id}-error`] const valid = input.validity.valid error.hidden = valid return valid } function validatePasswordConfirmation(passwordId, confirmationId) { const { submitted = false } = arguments[2] ?? {} const password = elements[passwordId] const confirmation = elements[confirmationId] const error = elements[`${confirmationId}-error`] if (confirmation.value === '') { if (submitted && password.value !== '') { error.textContent = t('authPasswordMismatch') error.hidden = false return false } error.hidden = true return true } if (password.value !== confirmation.value) { error.textContent = t('authPasswordMismatch') error.hidden = false return false } error.textContent = t('authPasswordFormat') error.hidden = true return true } function authenticationAccountDraft() { const username = elements['auth-username'].value const password = elements['auth-password'].value const additionalEnabled = elements['auth-additional-enabled'].checked const totpEnabled = elements['auth-totp-enabled'].checked const priorAdditionalEnabled = authenticationSettings?.account?.managementAdditionalCredential?.enabled === true const priorTotpEnabled = authenticationSettings?.account?.totp?.enabled === true const additionalPassword = elements['auth-additional-password'].value return { username, password, additionalEnabled, totpEnabled, additionalPassword, priorAdditionalEnabled, priorTotpEnabled, usernameChanged: username !== (authenticationSettings?.account?.username ?? ''), mainPasswordChanged: password !== '', additionalChanged: additionalEnabled !== priorAdditionalEnabled || additionalPassword !== '', totpChanged: totpEnabled !== priorTotpEnabled, } } function validateAdditionalPassword(draft) { if (!draft.additionalEnabled || draft.priorAdditionalEnabled || draft.additionalPassword !== '') return true const error = elements['auth-additional-password-error'] error.textContent = t('authManagementPasswordRequired') error.hidden = false return false } function accountDraftChanged(draft = authenticationAccountDraft()) { return draft.usernameChanged || draft.mainPasswordChanged || draft.additionalChanged || draft.totpChanged } function renderAuthenticationAccountSaveState() { const totpUnverified = pendingTotpChange !== undefined && pendingTotpChange.confirmed !== true elements['auth-totp-pending'].hidden = !totpUnverified elements['auth-totp-pending'].parentElement.dataset.state = totpUnverified ? 'unverified' : 'ready' elements['auth-account-save'].disabled = totpUnverified || !accountDraftChanged() } function validateCurrentPassword(draft, { incorrect = false } = {}) { const input = elements['auth-current-password'] const error = elements['auth-current-password-error'] if (!draft.mainPasswordChanged && !draft.additionalChanged && !draft.totpChanged) { error.hidden = true return true } if (input.value === '') { error.textContent = t('authCurrentPasswordRequired') error.hidden = false return false } if (incorrect) { error.textContent = t('authCurrentPasswordIncorrect') error.hidden = false return false } error.hidden = true return true } function renderPasswordConfirmation(passwordId, confirmationId, { submitted = false } = {}) { // Always recompute the state so clearing both fields removes a stale error. return validatePasswordConfirmation(passwordId, confirmationId, { submitted }) } function validateAuthenticationSettingsFormat() { const usernameValid = renderAuthenticationFormatError('auth-username') // Confirmation fields have their own mismatch validator; showing the // generic password-format hint under them obscures the actual problem. const passwordsValid = [ 'auth-password', 'auth-additional-password', ] .map(renderAuthenticationFormatError) .every(Boolean) return usernameValid && passwordsValid } function managementLoginPath() { return window.location.pathname.startsWith('/_dsh_platform/') ? '/_dsh_platform/auth/management' : '/auth/management' } function managementReturnDshPath() { return window.location.pathname.startsWith('/_dsh_platform/') ? '/_dsh_platform/auth/return-dsh' : '/auth/return-dsh' } function selectedManagementAccess() { const selected = elements['auth-mode'].value if (selected === 'compat') return { mode: 'compat', isolatedEntry: null, candidateOrigin: null } const input = (selected === 'isolated-local' ? elements['auth-local-port'] : elements['auth-origin']).value.trim() const candidateOrigin = selected === 'isolated-local' ? (/^\d{1,5}$/u.test(input) && Number(input) >= 1 && Number(input) <= 65535 ? `http://127.0.0.1:${input}` : input) : input return { mode: 'isolated', isolatedEntry: selected === 'isolated-local' ? { kind: 'local-only', managementLocalOrigin: candidateOrigin } : { kind: 'public', managementPublicOrigin: candidateOrigin }, candidateOrigin, } } function currentManagementAccessSelection(access = {}) { if (access.mode !== 'isolated') return { mode: 'compat', origin: '' } if (access.isolatedEntry?.kind === 'local-only') return { mode: 'isolated-local', origin: access.isolatedEntry.managementLocalOrigin ?? '' } return { mode: 'isolated-public', origin: access.isolatedEntry?.managementPublicOrigin ?? '' } } function detectedManagementOrigin() { const value = new URL(window.location.origin) value.port = '3081' return value.origin } function accessMode(value) { return value === 'compat' ? 'compat' : 'isolated' } function renderAdditionalPasswordFields() { const enabled = elements['auth-additional-enabled'].checked elements['auth-additional-fields'].hidden = !enabled if (!enabled) { for (const id of ['auth-additional-password', 'auth-additional-password-confirm']) { elements[id].value = '' elements[`${id}-error`].hidden = true } } } function browserDevice(userAgent) { if (typeof userAgent !== 'string' || userAgent === '') return t('authSessionUnknownDevice') const browser = /Edg\//.test(userAgent) ? 'Edge' : /Firefox\//.test(userAgent) ? 'Firefox' : /Chrome\//.test(userAgent) ? 'Chrome' : /Safari\//.test(userAgent) ? 'Safari' : t('authSessionUnknownDevice') const platform = /Android/.test(userAgent) ? 'Android' : /iPhone|iPad|iPod/.test(userAgent) ? 'iOS' : /Windows/.test(userAgent) ? 'Windows' : /Macintosh|Mac OS X/.test(userAgent) ? 'macOS' : /Linux/.test(userAgent) ? 'Linux' : '' return platform === '' ? browser : `${browser} · ${platform}` } function renderAuthenticationSessions(sessions) { const list = elements['auth-session-list'] list.replaceChildren() for (const session of sessions) { const row = document.createElement('div') row.className = 'auth-session-row' const details = document.createElement('div') const heading = document.createElement('div') heading.className = 'auth-session-heading' const device = document.createElement('span') device.className = 'auth-session-device' device.textContent = browserDevice(session.userAgent) if (typeof session.userAgent === 'string') device.title = session.userAgent heading.append(device) if (session.current === true) { const current = document.createElement('span') current.className = 'auth-session-current' current.textContent = t('authSessionCurrent') heading.append(current) } if (authenticationSettings?.account?.managementAdditionalCredential?.enabled === true && session.managementActive === true) { const management = document.createElement('span') management.className = 'auth-session-management' management.textContent = t('authSessionManagement') heading.append(management) } const meta = document.createElement('div') meta.className = 'auth-session-meta' for (const [key, value] of [ ['authSessionIp', session.ip ?? '—'], ['authSessionLoginAt', localTime(session.createdAt)], ['authSessionLastSeen', localTime(session.lastSeenAt)], ]) { const field = document.createElement('span') field.textContent = t(key, { value }) meta.append(field) } details.append(heading, meta) const button = document.createElement('button') button.type = 'button' button.className = 'auth-session-revoke secondary' button.dataset.sessionId = session.sessionId const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg') svg.setAttribute('viewBox', '0 0 24 24') svg.setAttribute('aria-hidden', 'true') for (const pathData of ['M10 17l5-5-5-5', 'M15 12H3', 'M15 4h4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-4']) { const path = document.createElementNS('http://www.w3.org/2000/svg', 'path') path.setAttribute('d', pathData) svg.append(path) } const label = document.createElement('span') label.textContent = t('authSessionRevoke') button.append(svg, label) button.addEventListener('click', () => { void revokeAuthenticationSession(button) }) row.append(details, button) list.append(row) } } async function loadAuthenticationSettings() { try { authenticationSettings = await api('auth-settings') pendingTotpChange = undefined const account = authenticationSettings.account ?? {} elements['auth-username'].value = account.username ?? '' elements['auth-totp-enabled'].checked = account.totp?.enabled === true elements['auth-additional-enabled'].checked = account.managementAdditionalCredential?.enabled === true renderAdditionalPasswordFields() const selectedAccess = currentManagementAccessSelection(account.managementAccess) elements['auth-mode'].value = selectedAccess.mode elements['auth-origin'].value = selectedAccess.mode === 'isolated-public' ? selectedAccess.origin : '' elements['auth-local-port'].value = selectedAccess.mode === 'isolated-local' ? (selectedAccess.origin.match(/:(\d+)$/u)?.[1] ?? '3081') : '3081' renderAuthenticationOrigin() const sessions = authenticationSettings.sessions ?? [] elements['auth-session-summary'].textContent = t('authSessionSummary', { count: sessions.length }) renderAuthenticationSessions(sessions) for (const id of ['auth-current-password', 'auth-password', 'auth-password-confirm', 'auth-additional-password', 'auth-additional-password-confirm']) { elements[id].value = '' elements[`${id}-error`].hidden = true } elements['auth-username-error'].hidden = true elements['auth-settings-status'].hidden = true renderAuthenticationAccountSaveState() } catch (error) { showError(error) } } function renderAuthenticationOrigin() { const selected = elements['auth-mode'].value const local = selected === 'isolated-local' const isolated = selected !== 'compat' const currentMode = authenticationSettings?.account?.managementAccess?.mode ?? 'compat' const rootEnabled = authenticationSettings?.dshRootCapabilityEffective === true for (const option of elements['auth-mode'].options) { option.disabled = rootEnabled && accessMode(option.value) !== currentMode } elements['auth-origin-settings'].hidden = !isolated elements['auth-public-origin-field'].hidden = selected !== 'isolated-public' elements['auth-local-port-field'].hidden = !local elements['auth-origin-detect'].hidden = selected !== 'isolated-public' elements['auth-origin-detail'].textContent = t(local ? 'authLocalPortDetail' : 'authPublicOriginDetail') elements['auth-origin'].required = selected === 'isolated-public' elements['auth-local-port'].required = local elements['auth-root-warning'].hidden = !rootEnabled if (rootEnabled) elements['auth-root-warning'].textContent = t('authRootLocked') renderManagementOriginSaveState() } function renderManagementOriginSaveState() { const previousAccess = currentManagementAccessSelection(authenticationSettings?.account?.managementAccess) const nextAccess = selectedManagementAccess() const changed = nextAccess.mode !== previousAccess.mode || (nextAccess.mode === 'isolated' && nextAccess.candidateOrigin !== previousAccess.origin) const localPort = elements['auth-mode'].value === 'isolated-local' ? elements['auth-local-port'].value.trim() : '' const localPortValid = localPort !== '' && /^\d+$/u.test(localPort) && Number(localPort) >= 1 && Number(localPort) <= 65535 elements['auth-local-port-error'].hidden = elements['auth-mode'].value !== 'isolated-local' || localPort === '' || localPortValid elements['auth-origin-save'].disabled = !changed || (elements['auth-mode'].value === 'isolated-local' && !localPortValid) } function confirmIsolationEnable() { const dialog = elements['auth-isolation-confirm-dialog'] dialog.returnValue = 'cancel' dialog.showModal() return new Promise(resolve => dialog.addEventListener('close', () => { resolve(dialog.returnValue === 'confirm') }, { once: true })) } async function probeManagementOrigin(created) { if (created.transition?.candidateOrigin === null) return null const target = new URL('/transition/probe', created.transition.candidateOrigin) target.searchParams.set('transitionId', created.transition.transitionId) target.searchParams.set('nonce', created.transition.nonce) for (let attempt = 0; attempt < 12; attempt += 1) { let response try { response = await fetch(target, { credentials: 'omit', headers: { accept: 'application/json' }, mode: 'cors', }) } catch { response = undefined } const result = await response?.json().catch(() => ({})) ?? {} if (response?.ok && typeof result.proof === 'string') return result.proof if (attempt < 11) await new Promise(resolve => window.setTimeout(resolve, 500)) } throw Object.assign(new Error(t('authTransitionFailed')), { userFacing: true }) } function continueManagementTransition(result) { const isolatedEntry = result.account?.managementAccess?.isolatedEntry const continuationOrigin = typeof result.targetOrigin === 'string' ? result.targetOrigin : result.account?.managementAccess?.mode === 'isolated' ? isolatedEntry?.kind === 'public' ? isolatedEntry.managementPublicOrigin : isolatedEntry?.managementLocalOrigin : result.loginOrigin if (result.continuation?.token !== undefined && typeof continuationOrigin === 'string') { const prefix = result.account?.managementAccess?.mode === 'isolated' ? '/transition/continue' : '/_dsh_platform/transition/continue' const target = new URL(prefix, continuationOrigin) target.searchParams.set('token', result.continuation.token) try { window.sessionStorage.setItem(MANAGEMENT_TRANSITION_TARGET_KEY, target.href) } catch {} window.location.replace(target) return } if (typeof result.loginOrigin === 'string') { // Let the destination Gateway and Access Manager decide whether this // browser needs authentication; navigation must not choose the login UI. window.location.assign(new URL('/', result.loginOrigin)) return } showAuthenticationStatus(t('authTransitionCompatLogin')) } async function changeManagementOrigin(access) { const created = await api('management-origin/transitions', { method: 'POST', body: access, }) let proof try { proof = await probeManagementOrigin(created) } catch (error) { await api('management-origin/transitions/failure', { method: 'POST', body: { stage: 'browser-probe', mode: access.mode }, }).catch(() => {}) throw error } const result = await api('management-origin/transitions/commit', { method: 'POST', body: { transitionId: created.transition.transitionId, proof, }, }) authenticationSettings.account = result.account continueManagementTransition(result) return result } async function saveAccountSettings() { const draft = authenticationAccountDraft() if (!accountDraftChanged(draft)) return const formatValid = validateAuthenticationSettingsFormat() const additionalPasswordValid = validateAdditionalPassword(draft) const currentPasswordValid = validateCurrentPassword(draft) // Do not authenticate or reject credentials in the browser. The Access // Manager validates the current password and returns the authoritative code. const confirmationsValid = [ renderPasswordConfirmation('auth-password', 'auth-password-confirm', { submitted: true }), renderPasswordConfirmation('auth-additional-password', 'auth-additional-password-confirm', { submitted: true }), ].every(Boolean) if (!formatValid || !additionalPasswordValid || !currentPasswordValid || !confirmationsValid) return const button = elements['auth-account-save'] button.disabled = true try { const body = { username: draft.username, currentPassword: elements['auth-current-password'].value } if (draft.mainPasswordChanged) body.password = draft.password if (draft.additionalChanged) body.additionalEnabled = draft.additionalEnabled if (draft.additionalEnabled && draft.additionalPassword !== '') body.additionalPassword = draft.additionalPassword if (draft.totpChanged && !draft.totpEnabled) { if (pendingTotpChange?.kind !== 'disable' || pendingTotpChange.confirmed !== true) return body.totpEnabled = false body.totpDisableConfirmationToken = pendingTotpChange.confirmation.confirmationToken } if (draft.totpChanged && draft.totpEnabled) { if (pendingTotpChange?.kind !== 'enable' || pendingTotpChange.confirmed !== true) return body.totpEnabled = true body.totpEnrollmentToken = pendingTotpChange.enrollment.enrollmentToken } const result = await api('auth-settings', { method: 'PUT', body }) finishAccountSettings(result) } catch (error) { if (error.code === 'FRESH_AUTH_FAILED') { validateCurrentPassword(draft, { incorrect: true }) elements['auth-current-password'].focus() } else if (['TOTP_ENROLLMENT_INVALID', 'TOTP_ENROLLMENT_UNCONFIRMED', 'TOTP_DISABLE_CONFIRMATION_INVALID', 'TOTP_DISABLE_CONFIRMATION_UNCONFIRMED'].includes(error.code)) { pendingTotpChange = undefined elements['auth-totp-enabled'].checked = authenticationSettings?.account?.totp?.enabled === true renderAuthenticationAccountSaveState() showError(new Error(t('authTotpEnrollmentExpired'))) } else showError(error) button.disabled = false } } function finishAccountSettings(result) { const button = elements['auth-account-save'] if (result.currentManagementSessionRevoked === true) { showAuthenticationStatus(t(result.allSessionsRevoked === true ? 'authSavedAllSessionsRevoked' : 'authSavedManagementSessionsRevoked')) window.setTimeout(() => { const returnPath = `${window.location.pathname}${window.location.search}` window.location.replace(returnPath) }, 900) } else { authenticationSettings.account = result.account button.disabled = false void loadAuthenticationSettings().then(() => showAuthenticationStatus(t('authSaved'))) } } function resetTotpDialog() { clearTimeout(totpRetryTimer) elements['auth-totp-qr'].removeAttribute('src') elements['auth-totp-secret'].textContent = '' elements['auth-totp-secret-row'].hidden = true elements['auth-totp-secret-toggle'].setAttribute('aria-expanded', 'false') elements['auth-totp-secret-toggle'].textContent = t('authTotpShowSecret') elements['auth-totp-code'].value = '' elements['auth-totp-code'].disabled = false elements['auth-totp-error'].hidden = true elements['auth-totp-confirm'].disabled = false elements['auth-totp-dialog-title'].textContent = t('authTotpSetupTitle') elements['auth-totp-dialog-detail'].textContent = t('authTotpSetupDetail') elements['auth-totp-qr'].hidden = false elements['auth-totp-secret-toggle'].hidden = false } function openTotpEnrollment(enrollment) { resetTotpDialog() pendingTotpChange = { kind: 'enable', enrollment, confirmed: false } elements['auth-totp-qr'].src = enrollment.qrCode elements['auth-totp-secret'].textContent = enrollment.secret renderAuthenticationAccountSaveState() elements['auth-totp-dialog'].showModal() elements['auth-totp-code'].focus() } function openTotpDisableConfirmation(confirmation) { resetTotpDialog() pendingTotpChange = { kind: 'disable', confirmation, confirmed: false } elements['auth-totp-dialog-title'].textContent = t('authTotpDisableTitle') elements['auth-totp-dialog-detail'].textContent = t('authTotpDisableDetail') elements['auth-totp-qr'].hidden = true elements['auth-totp-secret-toggle'].hidden = true renderAuthenticationAccountSaveState() elements['auth-totp-dialog'].showModal() elements['auth-totp-code'].focus() } async function cancelTotpChange({ resetToggle = true } = {}) { if (totpConfirming) return false const pending = pendingTotpChange clearTimeout(totpRetryTimer) elements['auth-totp-close'].disabled = true try { if (pending !== undefined) { const disabling = pending.kind === 'disable' await api(disabling ? 'auth-totp/disable-confirmations/cancel' : 'auth-totp/enrollments/cancel', { method: 'POST', body: disabling ? { confirmationToken: pending.confirmation.confirmationToken } : { enrollmentToken: pending.enrollment.enrollmentToken }, }) } } catch (error) { elements['auth-totp-error'].textContent = error.message elements['auth-totp-error'].hidden = false elements['auth-totp-close'].disabled = false return false } pendingTotpChange = undefined if (resetToggle) { elements['auth-totp-enabled'].checked = authenticationSettings?.account?.totp?.enabled === true } resetTotpDialog() elements['auth-totp-close'].disabled = false if (elements['auth-totp-dialog'].open) elements['auth-totp-dialog'].close() if (resetToggle) elements['auth-totp-enabled'].focus({ preventScroll: true }) elements['auth-account-save'].disabled = false renderAuthenticationAccountSaveState() return true } function discardTotpChangeOnPageLeave() { const pending = pendingTotpChange if (pending === undefined) return pendingTotpChange = undefined const disabling = pending.kind === 'disable' const body = disabling ? { confirmationToken: pending.confirmation?.confirmationToken } : { enrollmentToken: pending.enrollment?.enrollmentToken } elements['auth-totp-enabled'].checked = authenticationSettings?.account?.totp?.enabled === true resetTotpDialog() if (elements['auth-totp-dialog'].open) elements['auth-totp-dialog'].close() void fetch(`${API}/${disabling ? 'auth-totp/disable-confirmations/cancel' : 'auth-totp/enrollments/cancel'}`, { method: 'POST', headers: { 'content-type': 'application/json', 'x-dsh-csrf': browserCookie('dsh_management_csrf') }, body: JSON.stringify(body), keepalive: true, }).catch(() => {}) } async function changeTotpEnabled() { const input = elements['auth-totp-enabled'] const priorEnabled = authenticationSettings?.account?.totp?.enabled === true if (priorEnabled) { if (input.checked) { if (pendingTotpChange?.kind === 'disable') void cancelTotpChange({ resetToggle: false }) renderAuthenticationAccountSaveState() return } input.checked = true input.disabled = true pendingTotpChange = { kind: 'disable', confirmed: false } renderAuthenticationAccountSaveState() try { const confirmation = await api('auth-totp/disable-confirmations', { method: 'POST', body: {} }) openTotpDisableConfirmation(confirmation) } catch (error) { pendingTotpChange = undefined renderAuthenticationAccountSaveState() showError(error) } finally { input.disabled = false } return } if (!input.checked) { if (pendingTotpChange !== undefined && !await cancelTotpChange({ resetToggle: false })) input.checked = true renderAuthenticationAccountSaveState() return } if (pendingTotpChange?.confirmed === true) { renderAuthenticationAccountSaveState() return } const draft = authenticationAccountDraft() input.disabled = true renderAuthenticationAccountSaveState() try { const enrollment = await api('auth-totp/enrollments', { method: 'POST', body: { username: draft.username, }, }) openTotpEnrollment(enrollment) } catch (error) { input.checked = false renderAuthenticationAccountSaveState() showError(error) } finally { input.disabled = false } } function showTotpRetryCountdown(error) { const field = elements['auth-totp-error'] const input = elements['auth-totp-code'] const button = elements['auth-totp-confirm'] const template = t(error.code === 'TOTP_RATE_LIMITED' ? 'authTotpRateLimited' : 'authTotpRetryRequired') const retryUntil = Date.now() + error.retryAfterSeconds * 1000 input.disabled = true button.disabled = true const tick = () => { const seconds = Math.max(0, Math.ceil((retryUntil - Date.now()) / 1000)) if (seconds > 0) { field.textContent = template.replace('{seconds}', String(seconds)) field.hidden = false totpRetryTimer = window.setTimeout(tick, Math.min(1000, Math.max(1, retryUntil - Date.now()))) return } field.hidden = true input.disabled = false button.disabled = false input.focus() } tick() } async function confirmTotpEnrollment() { const pending = pendingTotpChange if (pending === undefined) return const code = elements['auth-totp-code'].value.trim() if (!/^\d{6}$/u.test(code)) { elements['auth-totp-error'].textContent = t('authTotpCodeRequired') elements['auth-totp-error'].hidden = false return } elements['auth-totp-confirm'].disabled = true elements['auth-totp-close'].disabled = true totpConfirming = true try { const disabling = pending.kind === 'disable' await api(disabling ? 'auth-totp/disable-confirmations/confirm' : 'auth-totp/enrollments/confirm', { method: 'POST', body: disabling ? { confirmationToken: pending.confirmation.confirmationToken, totpCode: code } : { enrollmentToken: pending.enrollment.enrollmentToken, totpCode: code }, }) pending.confirmed = true if (disabling) elements['auth-totp-enabled'].checked = false resetTotpDialog() elements['auth-totp-dialog'].close() renderAuthenticationAccountSaveState() showAuthenticationStatus(t('authTotpConfirmedDraft')) } catch (error) { if (error.code === 'TOTP_INVALID') { elements['auth-totp-error'].textContent = t('authTotpCodeInvalid') elements['auth-totp-error'].hidden = false elements['auth-totp-code'].select() elements['auth-totp-confirm'].disabled = false } else if (['TOTP_RETRY_REQUIRED', 'TOTP_RATE_LIMITED'].includes(error.code)) { showTotpRetryCountdown(error) } else if (['TOTP_ENROLLMENT_INVALID', 'TOTP_DISABLE_CONFIRMATION_INVALID'].includes(error.code)) { elements['auth-totp-error'].textContent = t(error.code === 'TOTP_ENROLLMENT_INVALID' ? 'authTotpEnrollmentExpired' : 'authTotpDisableExpired') elements['auth-totp-error'].hidden = false } else { elements['auth-totp-confirm'].disabled = false showError(error) } } finally { totpConfirming = false elements['auth-totp-close'].disabled = false } } function trapTotpDialogFocus(event) { if (event.key !== 'Tab') return const dialog = elements['auth-totp-dialog'] const focusable = [...dialog.querySelectorAll('button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])')] .filter(element => !element.hidden && element.getClientRects().length > 0) if (focusable.length === 0) return const first = focusable[0] const last = focusable.at(-1) if (event.shiftKey && (document.activeElement === first || !dialog.contains(document.activeElement))) { event.preventDefault() last.focus() } else if (!event.shiftKey && document.activeElement === last) { event.preventDefault() first.focus() } } // Keep native dialogs keyboard-contained and return focus to the control that opened them. const dialogFocusTriggers = new WeakMap() let pendingDialogTrigger document.addEventListener('click', event => { const trigger = event.target.closest?.('button, input, select, textarea, a, label') if (trigger === null || trigger === undefined) return pendingDialogTrigger = trigger window.queueMicrotask(() => { for (const dialog of document.querySelectorAll('dialog[open]')) { if (!dialogFocusTriggers.has(dialog) && pendingDialogTrigger !== undefined) dialogFocusTriggers.set(dialog, pendingDialogTrigger) } pendingDialogTrigger = undefined }) }, true) document.addEventListener('keydown', event => { if (event.key !== 'Tab') return const dialog = event.target.closest?.('dialog[open]') if (dialog === null || dialog === undefined || dialog.id === 'auth-totp-dialog') return const focusable = [...dialog.querySelectorAll('button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])')] .filter(element => element.getClientRects().length > 0 && !element.hidden) if (focusable.length === 0) return const first = focusable[0] const last = focusable.at(-1) if ((event.shiftKey && (document.activeElement === first || !dialog.contains(document.activeElement))) || (!event.shiftKey && document.activeElement === last)) { event.preventDefault() ;(event.shiftKey ? last : first).focus() } }, true) const dialogOpenObserver = new MutationObserver(records => { for (const record of records) { if (record.type !== 'attributes' || record.attributeName !== 'open') continue const dialog = record.target if (dialog.open && !dialogFocusTriggers.has(dialog)) { const active = document.activeElement if (active !== dialog && !dialog.contains(active)) dialogFocusTriggers.set(dialog, active) } } }) for (const dialog of document.querySelectorAll('dialog')) dialogOpenObserver.observe(dialog, { attributes: true }) for (const dialog of document.querySelectorAll('dialog')) { dialog.addEventListener('close', () => { const trigger = dialogFocusTriggers.get(dialog) dialogFocusTriggers.delete(dialog) if (trigger?.isConnected && !trigger.disabled) trigger.focus({ preventScroll: true }) }) } async function saveManagementOrigin() { const button = elements['auth-origin-save'] const currentMode = authenticationSettings?.account?.managementAccess?.mode ?? 'compat' const modeChanged = accessMode(elements['auth-mode'].value) !== currentMode const accessInput = elements['auth-mode'].value === 'isolated-local' ? elements['auth-local-port'] : elements['auth-origin'] if (elements['auth-mode'].value === 'isolated-local') { const localPort = accessInput.value.trim() if (localPort === '' || !/^\d+$/u.test(localPort) || Number(localPort) < 1 || Number(localPort) > 65535) { elements['auth-local-port-error'].hidden = false return } } else if (elements['auth-mode'].value !== 'compat' && !accessInput.reportValidity()) return button.disabled = true try { const previousAccess = currentManagementAccessSelection(authenticationSettings?.account?.managementAccess) const nextAccess = selectedManagementAccess() const accessChanged = elements['auth-mode'].value !== previousAccess.mode || (nextAccess.mode === 'isolated' && nextAccess.candidateOrigin !== previousAccess.origin) if (!accessChanged) { showAuthenticationStatus(t('authSaved')) return } if (modeChanged && nextAccess.mode === 'isolated' && !await confirmIsolationEnable()) return await changeManagementOrigin(nextAccess) } catch (error) { if (error.userFacing === true) { showAuthenticationStatus(error.message) } else showError(error) } finally { button.disabled = false } } async function revokeAuthenticationSession(button) { const buttons = [...document.querySelectorAll('.auth-session-revoke')] for (const value of buttons) value.disabled = true try { const result = await api('auth-sessions/revoke', { method: 'POST', body: { sessionId: button.dataset.sessionId }, }) if (result.currentSessionRevoked === true) { window.location.reload() return } await loadAuthenticationSettings() showAuthenticationStatus(t('authSessionsRevoked')) } catch (error) { showError(error) } finally { for (const value of buttons) value.disabled = false } } for (const button of tabButtons) { button.addEventListener('click', () => { void selectTab(button.dataset.tab) }) button.addEventListener('keydown', event => { if (!['ArrowLeft', 'ArrowRight'].includes(event.key)) return event.preventDefault() const offset = event.key === 'ArrowRight' ? 1 : -1 const target = tabButtons[(tabButtons.indexOf(button) + offset + tabButtons.length) % tabButtons.length] void selectTab(target.dataset.tab).then(changed => { if (changed) target.focus() }) }) } function commitListPageJump(key, prefix) { const input = elements[`${prefix}-page-jump`] const value = Number(input.value) const values = key === 'plugins' ? plugins : key === 'systemSkills' ? systemSkills : key === 'userSkills' ? userSkillInventory.skills ?? [] : userPluginInventory.plugins ?? [] const total = filteredResources(key, values).length const lastPage = Math.max(0, Math.ceil(total / listPageSizes[key]) - 1) listPages[key] = Number.isSafeInteger(value) ? Math.min(lastPage, Math.max(0, value - 1)) : listPages[key] renderInventory(key) input.value = String(listPages[key] + 1) } for (const [key, prefix] of Object.entries({ plugins: 'plugins', systemSkills: 'system-skills', userSkills: 'user-skills', userPlugins: 'user-plugins' })) { elements[`${prefix}-page-previous`].addEventListener('click', () => { listPages[key] = Math.max(0, listPages[key] - 1) renderInventory(key) }) elements[`${prefix}-page-next`].addEventListener('click', () => { listPages[key] += 1 renderInventory(key) }) elements[`${prefix}-page-size`].addEventListener('change', event => { const value = Number(event.target.value) if (!LIST_PAGE_SIZES.includes(value)) return listPageSizes[key] = value listPages[key] = 0 writeStorage(`${LIST_PAGE_SIZE_KEY_PREFIX}${key}`, String(value)) renderInventory(key) }) elements[`${prefix}-page-jump`].addEventListener('blur', () => commitListPageJump(key, prefix)) elements[`${prefix}-page-jump`].addEventListener('keydown', event => { if (event.key !== 'Enter') return event.preventDefault() commitListPageJump(key, prefix) }) elements[`${prefix}-search`].addEventListener('input', event => { listQueries[key] = event.target.value listPages[key] = 0 renderInventory(key) }) } elements['proxy-enabled'].addEventListener('change', event => { void saveProxyImmediate(configuration => { configuration.enabled = event.target.checked }) }) for (const input of document.querySelectorAll('[data-proxy-scope]')) { input.addEventListener('change', event => { void saveProxyImmediate(configuration => { configuration.scopes[event.target.dataset.proxyScope] = event.target.checked }) }) } elements['proxy-protocol'].addEventListener('change', event => { elements['proxy-remote-dns-row'].hidden = event.target.value !== 'socks5' renderProxySaveState() }) elements['proxy-password'].addEventListener('input', () => { if (elements['proxy-password'].value !== '') { elements['proxy-clear-password'].setAttribute('aria-pressed', 'false') elements['proxy-password'].disabled = false } renderProxyTransportWarning() renderProxySaveState() }) elements['proxy-username'].addEventListener('input', () => { renderProxyTransportWarning() renderProxySaveState() }) elements['proxy-clear-password'].addEventListener('click', event => { const selected = event.currentTarget.getAttribute('aria-pressed') !== 'true' event.currentTarget.setAttribute('aria-pressed', String(selected)) if (selected) elements['proxy-password'].value = '' elements['proxy-password'].disabled = selected renderProxyTransportWarning() renderProxySaveState() }) for (const id of ['proxy-host', 'proxy-port', 'proxy-direct-rules']) elements[id].addEventListener('input', renderProxySaveState) elements['proxy-remote-dns'].addEventListener('change', renderProxySaveState) elements['proxy-all-proxy'].addEventListener('change', renderProxySaveState) elements['proxy-advanced-toggle'].addEventListener('click', event => { const expanded = event.currentTarget.getAttribute('aria-expanded') !== 'true' event.currentTarget.setAttribute('aria-expanded', String(expanded)) elements['proxy-advanced'].hidden = !expanded }) elements['proxy-provider-search'].addEventListener('input', renderProxyProviders) elements['proxy-scope-help'].addEventListener('click', () => elements['proxy-scope-dialog'].showModal()) elements['proxy-component-alert'].addEventListener('click', () => elements['proxy-component-dialog'].showModal()) elements['proxy-component-dialog'].addEventListener('click', event => { if (event.target === event.currentTarget) event.currentTarget.close() }) elements['proxy-provider-info-dialog'].addEventListener('click', event => { if (event.target === event.currentTarget) event.currentTarget.close() }) elements['proxy-save'].addEventListener('click', () => { void saveProxyConfiguration() }) elements['proxy-test'].addEventListener('click', () => { void startProxyTest() }) elements['proxy-test-cancel'].addEventListener('click', async () => { const running = proxyTestTask?.status === 'starting' || proxyTestTask?.status === 'running' if (!running) { elements['proxy-test-dialog'].close() return } if (proxyTestTask?.taskId === undefined) return try { const task = await api(`proxy/test/tasks/${proxyTestTask.taskId}`, { method: 'DELETE' }) renderProxyTest(task) if (task.status === 'running') scheduleProxyTestPoll(task.taskId) } catch (error) { renderProxyTest(failedProxyTest(error)) } }) for (const button of channelButtons) { button.addEventListener('click', async () => { if (await act('channel', { method: 'PUT', body: { channel: button.dataset.channel } })) await checkUpdates('channel-change') }) } elements.check.addEventListener('click', () => { void checkUpdates('manual') }) elements.update.addEventListener('click', () => { void act('update', { method: 'POST' }) }) elements.rollback.addEventListener('click', () => { void act('rollback', { method: 'POST', body: { planId: rollbackPlan?.planId } }) }) elements['return-stable'].addEventListener('click', () => { elements['stable-snapshot'].textContent = localTime(rollbackPlan?.snapshot?.createdAt) elements['confirm-data-loss'].checked = false elements['confirm-stable'].disabled = true elements['stable-dialog'].showModal() }) elements['confirm-data-loss'].addEventListener('change', event => { elements['confirm-stable'].disabled = !event.target.checked }) elements['confirm-stable'].addEventListener('click', async () => { const planId = rollbackPlan?.planId elements['stable-dialog'].close() await act('return-stable', { method: 'POST', body: { planId, confirmDataLoss: true } }) }) elements['automatic-enabled'].addEventListener('change', event => { void saveAutomaticCheck({ enabled: event.target.checked }) }) elements['automatic-interval'].addEventListener('change', event => { void saveAutomaticCheck({ intervalSeconds: Number(event.target.value) }) }) elements['notifications-enabled'].addEventListener('change', event => { void saveAutomaticCheck({ notificationsEnabled: event.target.checked }) }) elements['language-switch'].addEventListener('change', event => { void (async () => { if (!await confirmDiscardChanges()) { event.target.value = locale return } fileEditorDirty = false writeStorage(LANGUAGE_KEY, event.target.value) window.location.reload() })() }) elements['theme-switch'].addEventListener('click', () => { const currentIndex = THEME_ORDER.indexOf(themePreference) themePreference = THEME_ORDER[(currentIndex + 1) % THEME_ORDER.length] writeStorage(THEME_KEY, themePreference === 'system' ? null : themePreference) applyTheme(themePreference) renderThemeControl() }) elements['auth-mode'].addEventListener('change', () => { const selected = elements['auth-mode'].value const current = currentManagementAccessSelection(authenticationSettings?.account?.managementAccess) if (selected === 'isolated-local') { elements['auth-local-port'].value = current.mode === selected ? (current.origin.match(/:(\d+)$/u)?.[1] ?? '3081') : '3081' } else if (selected === 'isolated-public') { elements['auth-origin'].value = current.mode === selected ? current.origin : '' } renderAuthenticationOrigin() }) elements['auth-origin'].addEventListener('input', renderManagementOriginSaveState) elements['auth-local-port'].addEventListener('input', renderManagementOriginSaveState) elements['auth-origin-detect'].addEventListener('click', () => { elements['auth-origin'].value = detectedManagementOrigin() elements['auth-origin'].focus() }) elements['auth-isolation-compare'].addEventListener('click', () => elements['auth-isolation-dialog'].showModal()) elements['auth-isolation-dialog'].addEventListener('click', event => { if (event.target === event.currentTarget) event.currentTarget.close() }) for (const id of ['auth-password', 'auth-additional-password']) { elements[id].addEventListener('input', () => { renderAuthenticationFormatError(id) renderPasswordConfirmation(id, `${id}-confirm`) renderAuthenticationAccountSaveState() }) } for (const [passwordId, confirmationId] of [ ['auth-password', 'auth-password-confirm'], ['auth-additional-password', 'auth-additional-password-confirm'], ]) { elements[confirmationId].addEventListener('input', () => { renderPasswordConfirmation(passwordId, confirmationId) renderAuthenticationAccountSaveState() }) } elements['auth-current-password'].addEventListener('input', () => { if (elements['auth-current-password'].value !== '') elements['auth-current-password-error'].hidden = true }) elements['auth-username'].addEventListener('input', () => { renderAuthenticationAccountSaveState() }) elements['auth-additional-enabled'].addEventListener('change', () => { renderAdditionalPasswordFields() renderAuthenticationAccountSaveState() }) elements['auth-totp-enabled'].addEventListener('change', () => { void changeTotpEnabled() }) elements['auth-totp-secret-toggle'].addEventListener('click', () => { const expanded = elements['auth-totp-secret-toggle'].getAttribute('aria-expanded') === 'true' elements['auth-totp-secret-toggle'].setAttribute('aria-expanded', String(!expanded)) elements['auth-totp-secret-row'].hidden = expanded elements['auth-totp-secret-toggle'].textContent = t(expanded ? 'authTotpShowSecret' : 'authTotpHideSecret') }) elements['auth-totp-close'].addEventListener('click', () => { void cancelTotpChange() }) elements['auth-totp-dialog'].addEventListener('cancel', event => { event.preventDefault() void cancelTotpChange() }) elements['auth-totp-dialog'].addEventListener('keydown', trapTotpDialogFocus) elements['auth-totp-form'].addEventListener('submit', event => { event.preventDefault() void confirmTotpEnrollment() }) elements['auth-totp-code'].addEventListener('input', () => { if (!elements['auth-totp-code'].disabled) elements['auth-totp-error'].hidden = true }) elements['auth-settings-form'].addEventListener('submit', event => { event.preventDefault() }) elements['auth-account-save'].addEventListener('click', () => { void saveAccountSettings() }) elements['auth-origin-save'].addEventListener('click', () => { void saveManagementOrigin() }) elements['restart-dsh'].addEventListener('click', () => elements['restart-dialog'].showModal()) elements['start-dsh'].addEventListener('click', () => { void act('start-dsh', { method: 'POST' }) }) elements['stop-dsh'].addEventListener('click', () => elements['stop-dialog'].showModal()) elements['cancel-system-plugin-changes'].addEventListener('click', () => { void cancelSystemPluginDraft() }) elements['apply-system-plugin-changes'].addEventListener('click', () => { void applySystemPluginDraft() }) elements['runtime-reset'].addEventListener('click', () => setRuntimeResetExpanded(!runtimeResetExpanded)) elements['cancel-runtime-reset'].addEventListener('click', () => setRuntimeResetExpanded(false)) elements['confirm-runtime-reset'].addEventListener('click', async () => { setRuntimeResetExpanded(false) await act('runtime/reset', { method: 'POST' }) }) elements['cancel-user-plugin-changes'].addEventListener('click', () => { userPluginDraft.clear() userPluginFeedback = null renderUserPlugins(runtimeBusy()) }) elements['apply-user-plugin-changes'].addEventListener('click', () => { void applyUserPluginDraft() }) elements['new-terminal'].addEventListener('click', () => { void createTerminalSession() }) elements['close-terminal'].addEventListener('click', () => { void closeTerminalSession() }) elements['terminal-screen'].addEventListener('pointerdown', () => terminalEmulator?.focus()) elements['file-back'].addEventListener('click', async () => { const path = fileHistory.at(-1) const current = filePath if (path !== undefined && await navigateFiles(path, { history: false })) { fileHistory.pop() fileFuture.push(current) renderFileNavigation() } }) elements['file-forward'].addEventListener('click', async () => { const path = fileFuture.at(-1) const current = filePath if (path !== undefined && await navigateFiles(path, { history: false })) { fileFuture.pop() fileHistory.push(current) renderFileNavigation() } }) elements['file-up'].addEventListener('click', () => { const parent = filePath === '/' ? '/' : filePath.slice(0, filePath.lastIndexOf('/')) || '/' void navigateFiles(parent) }) elements['file-path'].addEventListener('keydown', event => { if (event.key === 'Enter') void navigateFiles(event.target.value) }) elements['file-refresh'].addEventListener('click', () => { void navigateFiles(filePath, { history: false }) }) for (const button of document.querySelectorAll('[data-file-sort]')) button.addEventListener('click', () => { if (fileSort === button.dataset.fileSort) fileOrder = fileOrder === 'asc' ? 'desc' : 'asc' else { fileSort = button.dataset.fileSort; fileOrder = 'asc' } renderFileSort() void navigateFiles(filePath, { history: false }) }) elements['file-search'].addEventListener('input', renderFiles) elements['file-hidden'].addEventListener('change', renderFiles) elements['file-search-recursive'].addEventListener('click', () => { void recursiveFileSearch() }) elements['file-select-all'].addEventListener('change', event => { fileSelected = event.target.checked ? new Set(visibleFileEntries().map(entry => entry.path)) : new Set() renderFiles() }) elements['file-page-previous'].addEventListener('click', () => { const page = Math.max(0, filePageIndex - 1) void navigateFiles(filePath, { history: false, offset: page * filePageSize, pageIndex: page }) }) elements['file-page-next'].addEventListener('click', () => { const page = filePageIndex + 1 if (page * filePageSize < fileListing.total) void navigateFiles(filePath, { history: false, offset: page * filePageSize, pageIndex: page }) }) elements['file-page-size'].addEventListener('change', event => { filePageSize = Number(event.target.value) void navigateFiles(filePath, { history: false }) }) function commitFilePageJump() { const input = elements['file-page-jump'] const requested = Number(input.value) const lastPage = Math.max(0, Math.ceil(fileListing.total / filePageSize) - 1) const page = Number.isSafeInteger(requested) ? Math.min(lastPage, Math.max(0, requested - 1)) : filePageIndex input.value = String(page + 1) void navigateFiles(filePath, { history: false, offset: page * filePageSize, pageIndex: page }) } elements['file-page-jump'].addEventListener('blur', commitFilePageJump) elements['file-page-jump'].addEventListener('keydown', event => { if (event.key !== 'Enter') return event.preventDefault() commitFilePageJump() }) elements['file-new'].addEventListener('click', () => setFileCreateExpanded(!fileCreateExpanded)) for (const button of document.querySelectorAll('[data-file-create-kind]')) { button.addEventListener('click', () => { fileCreateKind = button.dataset.fileCreateKind; renderFileCreate() }) } elements['file-create-name'].addEventListener('input', renderFileCreate) elements['file-create-name'].addEventListener('keydown', event => { if (event.key === 'Escape') setFileCreateExpanded(false) }) elements['file-create-cancel'].addEventListener('click', () => setFileCreateExpanded(false)) elements['file-create-panel'].addEventListener('submit', event => { event.preventDefault(); void createFileEntry() }) elements['file-upload'].addEventListener('click', () => elements['file-upload-input'].click()) elements['file-upload-input'].addEventListener('change', event => { const files = [...event.target.files] event.target.value = '' if (files.length > 0) void uploadFiles(files) }) elements['file-upload-directory'].addEventListener('click', () => elements['file-upload-directory-input'].click()) elements['file-upload-directory-input'].addEventListener('change', event => { const files = [...event.target.files] event.target.value = '' if (files.length > 0) void uploadFiles(files) }) const fileDropTarget = document.querySelector('.file-main') fileDropTarget.addEventListener('dragenter', event => { if (![...(event.dataTransfer?.types ?? [])].includes('Files')) return event.preventDefault() fileDragDepth += 1 fileDropTarget.classList.add('file-dragging') }) fileDropTarget.addEventListener('dragover', event => { if (![...(event.dataTransfer?.types ?? [])].includes('Files')) return event.preventDefault() event.dataTransfer.dropEffect = 'copy' }) fileDropTarget.addEventListener('dragleave', event => { event.preventDefault() fileDragDepth = Math.max(0, fileDragDepth - 1) if (fileDragDepth === 0) fileDropTarget.classList.remove('file-dragging') }) fileDropTarget.addEventListener('drop', event => { event.preventDefault() fileDragDepth = 0 fileDropTarget.classList.remove('file-dragging') void droppedUploadItems(event.dataTransfer).then(items => { if (items.length > 0) return uploadFiles(items) }).catch(showError) }) elements['file-copy'].addEventListener('click', () => { fileClipboard = { operation: 'copy', sources: sourceDescriptors() } fileOperationMessage(t('clipboardCopy', { count: fileClipboard.sources.length })) renderFileSelection() }) elements['file-cut'].addEventListener('click', () => { fileClipboard = { operation: 'move', sources: sourceDescriptors() } fileOperationMessage(t('clipboardMove', { count: fileClipboard.sources.length })) renderFileSelection() }) elements['file-paste'].addEventListener('click', () => { void pasteFiles() }) elements['file-archive'].addEventListener('click', () => openArchivePanel('archive')) elements['file-extract'].addEventListener('click', () => openArchivePanel('extract')) elements['file-archive-cancel'].addEventListener('click', closeArchivePanel) elements['file-archive-panel'].addEventListener('submit', event => { event.preventDefault(); void submitArchive() }) for (const input of document.querySelectorAll('input[name="file-conflict-choice"]')) { input.addEventListener('change', () => { elements['file-conflict-confirm'].disabled = false }) } elements['file-conflict-cancel'].addEventListener('click', () => finishFileConflict({ choice: 'cancel', applyAll: false })) elements['file-conflict-dialog'].addEventListener('cancel', event => { event.preventDefault(); finishFileConflict({ choice: 'cancel', applyAll: false }) }) elements['file-conflict-form'].addEventListener('submit', event => { event.preventDefault() const choice = new FormData(event.currentTarget).get('file-conflict-choice') if (!['overwrite', 'rename', 'skip'].includes(choice)) return finishFileConflict({ choice, applyAll: elements['file-conflict-all'].checked }) }) elements['file-rename'].addEventListener('click', () => { void (async () => { const entry = selectedFileEntries()[0] if (entry === undefined) return const name = await requestTextInput({ title: t('renameItem'), detail: t('renameItemDetail'), value: entry.name, validate: value => fileNameIsValid(value) ? null : t('invalidFileName'), }) if (name === null || name === entry.name || name.trim() === '') return void startFileTask({ operation: 'rename', sources: [{ path: entry.path, revision: entry.revision }], destination: `${filePath}/${name}`, destinationRevision: fileListing.revision }) })() }) elements['file-delete'].addEventListener('click', () => { void (async () => { const sources = sourceDescriptors() if (sources.length === 0 || !await requestConfirmation({ title: t('deleteFilesTitle'), detail: t('confirmDeleteFiles', { count: sources.length }), confirmLabel: t('confirmDelete'), danger: true })) return void startFileTask({ operation: 'delete', sources }) })() }) elements['file-download'].addEventListener('click', () => { const entry = selectedFileEntries()[0] if (entry === undefined) return const link = document.createElement('a') link.href = `${API}/files/download?path=${encodeURIComponent(entry.path)}&revision=${encodeURIComponent(entry.revision)}` link.download = entry.type === 'directory' ? `${entry.name}.zip` : entry.name link.click() window.setTimeout(() => { void refreshFileTasks() }, 100) }) elements['file-attributes'].addEventListener('click', () => { if (fileAttributesEntry !== null) closeFileAttributes() else { const entry = selectedFileEntries()[0] if (entry !== undefined) openFileAttributes(entry) } }) elements['file-attributes-close'].addEventListener('click', closeFileAttributes) elements['file-attributes-cancel'].addEventListener('click', closeFileAttributes) elements['file-attributes-save'].addEventListener('click', () => { void applyFileAttributes() }) elements['file-attributes-mode'].addEventListener('input', event => { if (/^[0-7]{3,4}$/u.test(event.target.value)) syncPermissionChecks(Number.parseInt(event.target.value, 8)) }) elements['file-attributes-user'].addEventListener('input', event => { elements['file-attributes-group'].value = event.target.value }) for (const input of permissionInputs) input.addEventListener('change', syncModeFromPermissions) elements['file-task-cancel'].addEventListener('click', () => { if (fileActiveTask !== null) void api(`files/tasks/${fileActiveTask}`, { method: 'DELETE' }).catch(showError) }) elements['file-editor-content'].addEventListener('input', () => { fileEditorDirty = elements['file-editor-content'].value !== fileEditorOriginal updateEditorLines() renderFileEditorState() }) elements['file-editor-content'].addEventListener('scroll', updateEditorLines) elements['file-editor-save'].addEventListener('click', () => { void saveFileEditor(false) }) elements['file-editor-save-as'].addEventListener('click', () => { void saveFileEditor(true) }) elements['file-editor-reload'].addEventListener('click', () => { if (fileEditor !== null) void openFileEditor(fileEditor) }) elements['file-editor-close'].addEventListener('click', event => { event.preventDefault(); void closeFileEditor() }) elements['file-editor-dialog'].addEventListener('cancel', event => { event.preventDefault(); void closeFileEditor() }) elements['text-input-dialog-cancel'].addEventListener('click', () => finishTextInput(null)) elements['text-input-dialog'].addEventListener('cancel', event => { event.preventDefault(); finishTextInput(null) }) elements['text-input-dialog-form'].addEventListener('submit', event => { event.preventDefault() const value = elements['text-input-dialog-value'].value const error = textInputValidate?.(value) ?? null elements['text-input-dialog-error'].textContent = error ?? '' elements['text-input-dialog-error'].hidden = error === null if (error === null) finishTextInput(value) }) elements['confirmation-dialog-cancel'].addEventListener('click', () => finishConfirmation(false)) elements['confirmation-dialog'].addEventListener('cancel', event => { event.preventDefault(); finishConfirmation(false) }) elements['confirmation-dialog-form'].addEventListener('submit', event => { event.preventDefault(); finishConfirmation(true) }) elements['confirm-restart'].addEventListener('click', async () => { elements['restart-dialog'].close() await act('restart-dsh', { method: 'POST' }) }) elements['confirm-stop'].addEventListener('click', async () => { elements['stop-dialog'].close() await act('stop-dsh', { method: 'POST' }) }) elements['progress-full-log'].addEventListener('click', () => { if (progressLogUpdate?.taskId) elements['log-search'].value = String(progressLogUpdate.taskId) renderLogs() void selectTab('maintenance') }) elements['progress-dismiss'].addEventListener('click', () => { dismissedProgressTaskId = String(status?.update?.taskId ?? '') if (status !== undefined) render(status) }) elements['proxy-system-rules'].addEventListener('click', () => elements['proxy-system-rules-dialog'].showModal()) elements['log-limit'].value = String(logDisplayLimit) for (const element of [elements['log-search'], elements['log-source'], elements['log-level']]) { element.addEventListener(element.tagName === 'INPUT' ? 'input' : 'change', renderLogs) } elements['log-limit'].addEventListener('change', event => { const value = Number(event.target.value) if (!LOG_DISPLAY_LIMITS.includes(value)) return logDisplayLimit = value writeStorage(LOG_DISPLAY_LIMIT_KEY, String(value)) renderLogs() }) elements['auto-scroll'].addEventListener('change', event => { autoScroll = event.target.checked if (autoScroll) elements['log-list'].scrollTop = elements['log-list'].scrollHeight }) elements['refresh-logs'].addEventListener('click', () => { void refreshLogs() }) elements['export-logs'].addEventListener('click', () => downloadLogJsonl(filteredRawLogs())) elements['clear-logs'].addEventListener('click', () => { const latest = logEntries.reduce((value, entry) => { const timestamp = Date.parse(entry.value.timestamp) return Number.isFinite(timestamp) ? Math.max(value, timestamp) : value }, Date.now()) logClearCutoff = new Date(latest).toISOString() try { window.sessionStorage.setItem(LOG_CLEAR_CUTOFF_KEY, logClearCutoff) } catch {} logEntries.length = 0 logIdentities.clear() expandedLogIdentities.clear() renderLogs() }) window.addEventListener('beforeunload', () => { terminalLeaving = true clearTimeout(terminalReconnectTimer) terminalSocket?.close() eventSource?.close() logSource?.close() window.clearInterval(logWatchdogTimer) }) window.addEventListener('pagehide', discardTotpChangeOnPageLeave) window.addEventListener('focus', refreshVisibleProxyProviders) document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') refreshVisibleProxyProviders() }) elements['return-dsh'].addEventListener('click', () => { window.location.assign(managementReturnDshPath()) }) applyTheme(themePreference) applyTranslations() void selectTab(window.location.hash === '#auth-settings' ? 'auth-settings' : 'maintenance') renderLogs() connectEvents() startManagementSessionGuard() void (async () => { const initial = await loadStatus() void loadProxy() await discardSystemPluginDraft() if (initial !== undefined && UPDATE_TERMINAL_STATES.has(initial.update?.status ?? 'idle')) void checkUpdates('page-open') })() window.setInterval(() => { void loadStatus() }, 15_000) window.setInterval(() => { void api('status').catch(() => {}) }, 5_000) window.setInterval(() => { if (status?.runtimeReset?.status === 'resetting') void loadStatus() }, 1_000)