5736 lines
300 KiB
JavaScript
5736 lines
300 KiB
JavaScript
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)
|