im-hub-pro 0.2.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +497 -0
- package/README.zh-CN.md +496 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +921 -0
- package/dist/cli.js.map +1 -0
- package/dist/core/acp-server.d.ts +8 -0
- package/dist/core/acp-server.d.ts.map +1 -0
- package/dist/core/acp-server.js +266 -0
- package/dist/core/acp-server.js.map +1 -0
- package/dist/core/agent-base.d.ts +94 -0
- package/dist/core/agent-base.d.ts.map +1 -0
- package/dist/core/agent-base.js +374 -0
- package/dist/core/agent-base.js.map +1 -0
- package/dist/core/agent-cwd.d.ts +45 -0
- package/dist/core/agent-cwd.d.ts.map +1 -0
- package/dist/core/agent-cwd.js +178 -0
- package/dist/core/agent-cwd.js.map +1 -0
- package/dist/core/agent-cwd.test.d.ts +2 -0
- package/dist/core/agent-cwd.test.d.ts.map +1 -0
- package/dist/core/agent-cwd.test.js +149 -0
- package/dist/core/agent-cwd.test.js.map +1 -0
- package/dist/core/approval-bus.d.ts +232 -0
- package/dist/core/approval-bus.d.ts.map +1 -0
- package/dist/core/approval-bus.js +703 -0
- package/dist/core/approval-bus.js.map +1 -0
- package/dist/core/approval-bus.synthetic.test.d.ts +2 -0
- package/dist/core/approval-bus.synthetic.test.d.ts.map +1 -0
- package/dist/core/approval-bus.synthetic.test.js +182 -0
- package/dist/core/approval-bus.synthetic.test.js.map +1 -0
- package/dist/core/approval-bus.test.d.ts +2 -0
- package/dist/core/approval-bus.test.d.ts.map +1 -0
- package/dist/core/approval-bus.test.js +537 -0
- package/dist/core/approval-bus.test.js.map +1 -0
- package/dist/core/approval-router.d.ts +95 -0
- package/dist/core/approval-router.d.ts.map +1 -0
- package/dist/core/approval-router.js +450 -0
- package/dist/core/approval-router.js.map +1 -0
- package/dist/core/approval-router.test.d.ts +2 -0
- package/dist/core/approval-router.test.d.ts.map +1 -0
- package/dist/core/approval-router.test.js +413 -0
- package/dist/core/approval-router.test.js.map +1 -0
- package/dist/core/audit-log.d.ts +55 -0
- package/dist/core/audit-log.d.ts.map +1 -0
- package/dist/core/audit-log.js +203 -0
- package/dist/core/audit-log.js.map +1 -0
- package/dist/core/bgjob-reader.d.ts +65 -0
- package/dist/core/bgjob-reader.d.ts.map +1 -0
- package/dist/core/bgjob-reader.js +212 -0
- package/dist/core/bgjob-reader.js.map +1 -0
- package/dist/core/bgjob-reader.test.d.ts +2 -0
- package/dist/core/bgjob-reader.test.d.ts.map +1 -0
- package/dist/core/bgjob-reader.test.js +178 -0
- package/dist/core/bgjob-reader.test.js.map +1 -0
- package/dist/core/circuit-breaker.d.ts +37 -0
- package/dist/core/circuit-breaker.d.ts.map +1 -0
- package/dist/core/circuit-breaker.js +115 -0
- package/dist/core/circuit-breaker.js.map +1 -0
- package/dist/core/commands/agent.d.ts +4 -0
- package/dist/core/commands/agent.d.ts.map +1 -0
- package/dist/core/commands/agent.js +21 -0
- package/dist/core/commands/agent.js.map +1 -0
- package/dist/core/commands/approval.d.ts +3 -0
- package/dist/core/commands/approval.d.ts.map +1 -0
- package/dist/core/commands/approval.js +44 -0
- package/dist/core/commands/approval.js.map +1 -0
- package/dist/core/commands/approval.test.d.ts +2 -0
- package/dist/core/commands/approval.test.d.ts.map +1 -0
- package/dist/core/commands/approval.test.js +85 -0
- package/dist/core/commands/approval.test.js.map +1 -0
- package/dist/core/commands/audit.d.ts +3 -0
- package/dist/core/commands/audit.d.ts.map +1 -0
- package/dist/core/commands/audit.js +84 -0
- package/dist/core/commands/audit.js.map +1 -0
- package/dist/core/commands/builtin.d.ts +3 -0
- package/dist/core/commands/builtin.d.ts.map +1 -0
- package/dist/core/commands/builtin.js +26 -0
- package/dist/core/commands/builtin.js.map +1 -0
- package/dist/core/commands/job.d.ts +3 -0
- package/dist/core/commands/job.d.ts.map +1 -0
- package/dist/core/commands/job.js +195 -0
- package/dist/core/commands/job.js.map +1 -0
- package/dist/core/commands/model.d.ts +9 -0
- package/dist/core/commands/model.d.ts.map +1 -0
- package/dist/core/commands/model.js +183 -0
- package/dist/core/commands/model.js.map +1 -0
- package/dist/core/commands/plan.d.ts +3 -0
- package/dist/core/commands/plan.d.ts.map +1 -0
- package/dist/core/commands/plan.js +75 -0
- package/dist/core/commands/plan.js.map +1 -0
- package/dist/core/commands/plan.test.d.ts +2 -0
- package/dist/core/commands/plan.test.d.ts.map +1 -0
- package/dist/core/commands/plan.test.js +122 -0
- package/dist/core/commands/plan.test.js.map +1 -0
- package/dist/core/commands/router.d.ts +3 -0
- package/dist/core/commands/router.d.ts.map +1 -0
- package/dist/core/commands/router.js +71 -0
- package/dist/core/commands/router.js.map +1 -0
- package/dist/core/commands/schedule.d.ts +3 -0
- package/dist/core/commands/schedule.d.ts.map +1 -0
- package/dist/core/commands/schedule.js +123 -0
- package/dist/core/commands/schedule.js.map +1 -0
- package/dist/core/commands/sessions.d.ts +3 -0
- package/dist/core/commands/sessions.d.ts.map +1 -0
- package/dist/core/commands/sessions.js +88 -0
- package/dist/core/commands/sessions.js.map +1 -0
- package/dist/core/commands/stats.d.ts +3 -0
- package/dist/core/commands/stats.d.ts.map +1 -0
- package/dist/core/commands/stats.js +73 -0
- package/dist/core/commands/stats.js.map +1 -0
- package/dist/core/commands/think.d.ts +3 -0
- package/dist/core/commands/think.d.ts.map +1 -0
- package/dist/core/commands/think.js +28 -0
- package/dist/core/commands/think.js.map +1 -0
- package/dist/core/commands/workspaces.d.ts +3 -0
- package/dist/core/commands/workspaces.d.ts.map +1 -0
- package/dist/core/commands/workspaces.js +47 -0
- package/dist/core/commands/workspaces.js.map +1 -0
- package/dist/core/config-schema.d.ts +58 -0
- package/dist/core/config-schema.d.ts.map +1 -0
- package/dist/core/config-schema.js +63 -0
- package/dist/core/config-schema.js.map +1 -0
- package/dist/core/cron.d.ts +29 -0
- package/dist/core/cron.d.ts.map +1 -0
- package/dist/core/cron.js +184 -0
- package/dist/core/cron.js.map +1 -0
- package/dist/core/event-bus.d.ts +80 -0
- package/dist/core/event-bus.d.ts.map +1 -0
- package/dist/core/event-bus.js +62 -0
- package/dist/core/event-bus.js.map +1 -0
- package/dist/core/intent-llm.d.ts +27 -0
- package/dist/core/intent-llm.d.ts.map +1 -0
- package/dist/core/intent-llm.js +170 -0
- package/dist/core/intent-llm.js.map +1 -0
- package/dist/core/intent.d.ts +12 -0
- package/dist/core/intent.d.ts.map +1 -0
- package/dist/core/intent.js +187 -0
- package/dist/core/intent.js.map +1 -0
- package/dist/core/job-board.d.ts +84 -0
- package/dist/core/job-board.d.ts.map +1 -0
- package/dist/core/job-board.js +379 -0
- package/dist/core/job-board.js.map +1 -0
- package/dist/core/logger.d.ts +6 -0
- package/dist/core/logger.d.ts.map +1 -0
- package/dist/core/logger.js +54 -0
- package/dist/core/logger.js.map +1 -0
- package/dist/core/metrics.d.ts +55 -0
- package/dist/core/metrics.d.ts.map +1 -0
- package/dist/core/metrics.js +291 -0
- package/dist/core/metrics.js.map +1 -0
- package/dist/core/onboarding.d.ts +94 -0
- package/dist/core/onboarding.d.ts.map +1 -0
- package/dist/core/onboarding.js +426 -0
- package/dist/core/onboarding.js.map +1 -0
- package/dist/core/onboarding.test.d.ts +2 -0
- package/dist/core/onboarding.test.d.ts.map +1 -0
- package/dist/core/onboarding.test.js +112 -0
- package/dist/core/onboarding.test.js.map +1 -0
- package/dist/core/rate-limiter.d.ts +44 -0
- package/dist/core/rate-limiter.d.ts.map +1 -0
- package/dist/core/rate-limiter.js +115 -0
- package/dist/core/rate-limiter.js.map +1 -0
- package/dist/core/registry.d.ts +32 -0
- package/dist/core/registry.d.ts.map +1 -0
- package/dist/core/registry.js +122 -0
- package/dist/core/registry.js.map +1 -0
- package/dist/core/router.d.ts +41 -0
- package/dist/core/router.d.ts.map +1 -0
- package/dist/core/router.js +431 -0
- package/dist/core/router.js.map +1 -0
- package/dist/core/schedule.d.ts +65 -0
- package/dist/core/schedule.d.ts.map +1 -0
- package/dist/core/schedule.js +316 -0
- package/dist/core/schedule.js.map +1 -0
- package/dist/core/session-subtasks.test.d.ts +2 -0
- package/dist/core/session-subtasks.test.d.ts.map +1 -0
- package/dist/core/session-subtasks.test.js +88 -0
- package/dist/core/session-subtasks.test.js.map +1 -0
- package/dist/core/session.d.ts +182 -0
- package/dist/core/session.d.ts.map +1 -0
- package/dist/core/session.js +774 -0
- package/dist/core/session.js.map +1 -0
- package/dist/core/sqlite-helper.d.ts +37 -0
- package/dist/core/sqlite-helper.d.ts.map +1 -0
- package/dist/core/sqlite-helper.js +79 -0
- package/dist/core/sqlite-helper.js.map +1 -0
- package/dist/core/transcribe.d.ts +25 -0
- package/dist/core/transcribe.d.ts.map +1 -0
- package/dist/core/transcribe.js +217 -0
- package/dist/core/transcribe.js.map +1 -0
- package/dist/core/transcribe.test.d.ts +2 -0
- package/dist/core/transcribe.test.d.ts.map +1 -0
- package/dist/core/transcribe.test.js +163 -0
- package/dist/core/transcribe.test.js.map +1 -0
- package/dist/core/types.d.ts +352 -0
- package/dist/core/types.d.ts.map +1 -0
- package/dist/core/types.js +3 -0
- package/dist/core/types.js.map +1 -0
- package/dist/core/workspace.d.ts +67 -0
- package/dist/core/workspace.d.ts.map +1 -0
- package/dist/core/workspace.js +113 -0
- package/dist/core/workspace.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/plugins/agents/acp/acp-adapter.d.ts +16 -0
- package/dist/plugins/agents/acp/acp-adapter.d.ts.map +1 -0
- package/dist/plugins/agents/acp/acp-adapter.js +49 -0
- package/dist/plugins/agents/acp/acp-adapter.js.map +1 -0
- package/dist/plugins/agents/acp/acp-client.d.ts +32 -0
- package/dist/plugins/agents/acp/acp-client.d.ts.map +1 -0
- package/dist/plugins/agents/acp/acp-client.js +175 -0
- package/dist/plugins/agents/acp/acp-client.js.map +1 -0
- package/dist/plugins/agents/acp/discovery.d.ts +19 -0
- package/dist/plugins/agents/acp/discovery.d.ts.map +1 -0
- package/dist/plugins/agents/acp/discovery.js +109 -0
- package/dist/plugins/agents/acp/discovery.js.map +1 -0
- package/dist/plugins/agents/acp/index.d.ts +4 -0
- package/dist/plugins/agents/acp/index.d.ts.map +1 -0
- package/dist/plugins/agents/acp/index.js +4 -0
- package/dist/plugins/agents/acp/index.js.map +1 -0
- package/dist/plugins/agents/acp/types.d.ts +62 -0
- package/dist/plugins/agents/acp/types.d.ts.map +1 -0
- package/dist/plugins/agents/acp/types.js +5 -0
- package/dist/plugins/agents/acp/types.js.map +1 -0
- package/dist/plugins/agents/claude-code/adapter.test.d.ts +2 -0
- package/dist/plugins/agents/claude-code/adapter.test.d.ts.map +1 -0
- package/dist/plugins/agents/claude-code/adapter.test.js +195 -0
- package/dist/plugins/agents/claude-code/adapter.test.js.map +1 -0
- package/dist/plugins/agents/claude-code/index.d.ts +25 -0
- package/dist/plugins/agents/claude-code/index.d.ts.map +1 -0
- package/dist/plugins/agents/claude-code/index.js +184 -0
- package/dist/plugins/agents/claude-code/index.js.map +1 -0
- package/dist/plugins/agents/claude-code/mcp-approval-server.d.ts +42 -0
- package/dist/plugins/agents/claude-code/mcp-approval-server.d.ts.map +1 -0
- package/dist/plugins/agents/claude-code/mcp-approval-server.js +235 -0
- package/dist/plugins/agents/claude-code/mcp-approval-server.js.map +1 -0
- package/dist/plugins/agents/claude-code/mcp-approval-server.test.d.ts +2 -0
- package/dist/plugins/agents/claude-code/mcp-approval-server.test.d.ts.map +1 -0
- package/dist/plugins/agents/claude-code/mcp-approval-server.test.js +188 -0
- package/dist/plugins/agents/claude-code/mcp-approval-server.test.js.map +1 -0
- package/dist/plugins/agents/codex/adapter.test.d.ts +2 -0
- package/dist/plugins/agents/codex/adapter.test.d.ts.map +1 -0
- package/dist/plugins/agents/codex/adapter.test.js +192 -0
- package/dist/plugins/agents/codex/adapter.test.js.map +1 -0
- package/dist/plugins/agents/codex/index.d.ts +37 -0
- package/dist/plugins/agents/codex/index.d.ts.map +1 -0
- package/dist/plugins/agents/codex/index.js +254 -0
- package/dist/plugins/agents/codex/index.js.map +1 -0
- package/dist/plugins/agents/copilot/index.d.ts +35 -0
- package/dist/plugins/agents/copilot/index.d.ts.map +1 -0
- package/dist/plugins/agents/copilot/index.js +182 -0
- package/dist/plugins/agents/copilot/index.js.map +1 -0
- package/dist/plugins/agents/opencode/adapter.test.d.ts +2 -0
- package/dist/plugins/agents/opencode/adapter.test.d.ts.map +1 -0
- package/dist/plugins/agents/opencode/adapter.test.js +139 -0
- package/dist/plugins/agents/opencode/adapter.test.js.map +1 -0
- package/dist/plugins/agents/opencode/http-adapter.test.d.ts +2 -0
- package/dist/plugins/agents/opencode/http-adapter.test.d.ts.map +1 -0
- package/dist/plugins/agents/opencode/http-adapter.test.js +492 -0
- package/dist/plugins/agents/opencode/http-adapter.test.js.map +1 -0
- package/dist/plugins/agents/opencode/index.d.ts +5 -0
- package/dist/plugins/agents/opencode/index.d.ts.map +1 -0
- package/dist/plugins/agents/opencode/index.js +30 -0
- package/dist/plugins/agents/opencode/index.js.map +1 -0
- package/dist/plugins/agents/opencode/opencode-http-adapter.d.ts +138 -0
- package/dist/plugins/agents/opencode/opencode-http-adapter.d.ts.map +1 -0
- package/dist/plugins/agents/opencode/opencode-http-adapter.js +549 -0
- package/dist/plugins/agents/opencode/opencode-http-adapter.js.map +1 -0
- package/dist/plugins/agents/opencode/opencode-stdio-adapter.d.ts +24 -0
- package/dist/plugins/agents/opencode/opencode-stdio-adapter.d.ts.map +1 -0
- package/dist/plugins/agents/opencode/opencode-stdio-adapter.js +103 -0
- package/dist/plugins/agents/opencode/opencode-stdio-adapter.js.map +1 -0
- package/dist/plugins/agents/opencode/serve-manager.d.ts +27 -0
- package/dist/plugins/agents/opencode/serve-manager.d.ts.map +1 -0
- package/dist/plugins/agents/opencode/serve-manager.js +190 -0
- package/dist/plugins/agents/opencode/serve-manager.js.map +1 -0
- package/dist/plugins/messengers/discord/discord-adapter.d.ts +22 -0
- package/dist/plugins/messengers/discord/discord-adapter.d.ts.map +1 -0
- package/dist/plugins/messengers/discord/discord-adapter.js +241 -0
- package/dist/plugins/messengers/discord/discord-adapter.js.map +1 -0
- package/dist/plugins/messengers/discord/discord-adapter.test.d.ts +2 -0
- package/dist/plugins/messengers/discord/discord-adapter.test.d.ts.map +1 -0
- package/dist/plugins/messengers/discord/discord-adapter.test.js +332 -0
- package/dist/plugins/messengers/discord/discord-adapter.test.js.map +1 -0
- package/dist/plugins/messengers/discord/index.d.ts +4 -0
- package/dist/plugins/messengers/discord/index.d.ts.map +1 -0
- package/dist/plugins/messengers/discord/index.js +4 -0
- package/dist/plugins/messengers/discord/index.js.map +1 -0
- package/dist/plugins/messengers/discord/markdown-to-discord.d.ts +11 -0
- package/dist/plugins/messengers/discord/markdown-to-discord.d.ts.map +1 -0
- package/dist/plugins/messengers/discord/markdown-to-discord.js +59 -0
- package/dist/plugins/messengers/discord/markdown-to-discord.js.map +1 -0
- package/dist/plugins/messengers/discord/types.d.ts +9 -0
- package/dist/plugins/messengers/discord/types.d.ts.map +1 -0
- package/dist/plugins/messengers/discord/types.js +3 -0
- package/dist/plugins/messengers/discord/types.js.map +1 -0
- package/dist/plugins/messengers/feishu/card-builder.d.ts +23 -0
- package/dist/plugins/messengers/feishu/card-builder.d.ts.map +1 -0
- package/dist/plugins/messengers/feishu/card-builder.js +89 -0
- package/dist/plugins/messengers/feishu/card-builder.js.map +1 -0
- package/dist/plugins/messengers/feishu/feishu-adapter.d.ts +33 -0
- package/dist/plugins/messengers/feishu/feishu-adapter.d.ts.map +1 -0
- package/dist/plugins/messengers/feishu/feishu-adapter.js +195 -0
- package/dist/plugins/messengers/feishu/feishu-adapter.js.map +1 -0
- package/dist/plugins/messengers/feishu/feishu-client.d.ts +44 -0
- package/dist/plugins/messengers/feishu/feishu-client.d.ts.map +1 -0
- package/dist/plugins/messengers/feishu/feishu-client.js +120 -0
- package/dist/plugins/messengers/feishu/feishu-client.js.map +1 -0
- package/dist/plugins/messengers/feishu/feishu-dedup.test.d.ts +2 -0
- package/dist/plugins/messengers/feishu/feishu-dedup.test.d.ts.map +1 -0
- package/dist/plugins/messengers/feishu/feishu-dedup.test.js +70 -0
- package/dist/plugins/messengers/feishu/feishu-dedup.test.js.map +1 -0
- package/dist/plugins/messengers/feishu/index.d.ts +4 -0
- package/dist/plugins/messengers/feishu/index.d.ts.map +1 -0
- package/dist/plugins/messengers/feishu/index.js +4 -0
- package/dist/plugins/messengers/feishu/index.js.map +1 -0
- package/dist/plugins/messengers/feishu/types.d.ts +113 -0
- package/dist/plugins/messengers/feishu/types.d.ts.map +1 -0
- package/dist/plugins/messengers/feishu/types.js +4 -0
- package/dist/plugins/messengers/feishu/types.js.map +1 -0
- package/dist/plugins/messengers/telegram/index.d.ts +4 -0
- package/dist/plugins/messengers/telegram/index.d.ts.map +1 -0
- package/dist/plugins/messengers/telegram/index.js +4 -0
- package/dist/plugins/messengers/telegram/index.js.map +1 -0
- package/dist/plugins/messengers/telegram/markdown-to-html.d.ts +5 -0
- package/dist/plugins/messengers/telegram/markdown-to-html.d.ts.map +1 -0
- package/dist/plugins/messengers/telegram/markdown-to-html.js +186 -0
- package/dist/plugins/messengers/telegram/markdown-to-html.js.map +1 -0
- package/dist/plugins/messengers/telegram/media-download.d.ts +51 -0
- package/dist/plugins/messengers/telegram/media-download.d.ts.map +1 -0
- package/dist/plugins/messengers/telegram/media-download.js +224 -0
- package/dist/plugins/messengers/telegram/media-download.js.map +1 -0
- package/dist/plugins/messengers/telegram/media-download.test.d.ts +2 -0
- package/dist/plugins/messengers/telegram/media-download.test.d.ts.map +1 -0
- package/dist/plugins/messengers/telegram/media-download.test.js +125 -0
- package/dist/plugins/messengers/telegram/media-download.test.js.map +1 -0
- package/dist/plugins/messengers/telegram/telegram-adapter.d.ts +62 -0
- package/dist/plugins/messengers/telegram/telegram-adapter.d.ts.map +1 -0
- package/dist/plugins/messengers/telegram/telegram-adapter.js +653 -0
- package/dist/plugins/messengers/telegram/telegram-adapter.js.map +1 -0
- package/dist/plugins/messengers/telegram/types.d.ts +47 -0
- package/dist/plugins/messengers/telegram/types.d.ts.map +1 -0
- package/dist/plugins/messengers/telegram/types.js +3 -0
- package/dist/plugins/messengers/telegram/types.js.map +1 -0
- package/dist/plugins/messengers/wechat/ilink-adapter.d.ts +68 -0
- package/dist/plugins/messengers/wechat/ilink-adapter.d.ts.map +1 -0
- package/dist/plugins/messengers/wechat/ilink-adapter.js +483 -0
- package/dist/plugins/messengers/wechat/ilink-adapter.js.map +1 -0
- package/dist/plugins/messengers/wechat/ilink-client.d.ts +66 -0
- package/dist/plugins/messengers/wechat/ilink-client.d.ts.map +1 -0
- package/dist/plugins/messengers/wechat/ilink-client.js +288 -0
- package/dist/plugins/messengers/wechat/ilink-client.js.map +1 -0
- package/dist/plugins/messengers/wechat/ilink-types.d.ts +173 -0
- package/dist/plugins/messengers/wechat/ilink-types.d.ts.map +1 -0
- package/dist/plugins/messengers/wechat/ilink-types.js +12 -0
- package/dist/plugins/messengers/wechat/ilink-types.js.map +1 -0
- package/dist/utils/backoff.d.ts +35 -0
- package/dist/utils/backoff.d.ts.map +1 -0
- package/dist/utils/backoff.js +59 -0
- package/dist/utils/backoff.js.map +1 -0
- package/dist/utils/cross-platform.d.ts +26 -0
- package/dist/utils/cross-platform.d.ts.map +1 -0
- package/dist/utils/cross-platform.js +58 -0
- package/dist/utils/cross-platform.js.map +1 -0
- package/dist/utils/message-split.d.ts +14 -0
- package/dist/utils/message-split.d.ts.map +1 -0
- package/dist/utils/message-split.js +65 -0
- package/dist/utils/message-split.js.map +1 -0
- package/dist/utils/safe-equal.d.ts +2 -0
- package/dist/utils/safe-equal.d.ts.map +1 -0
- package/dist/utils/safe-equal.js +11 -0
- package/dist/utils/safe-equal.js.map +1 -0
- package/dist/web/public/_app.js +196 -0
- package/dist/web/public/index.html +935 -0
- package/dist/web/public/settings.html +1181 -0
- package/dist/web/public/tasks.html +1827 -0
- package/dist/web/server.d.ts +11 -0
- package/dist/web/server.d.ts.map +1 -0
- package/dist/web/server.js +1820 -0
- package/dist/web/server.js.map +1 -0
- package/package.json +73 -0
|
@@ -0,0 +1,1827 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>im-hub-pro — Tasks</title>
|
|
7
|
+
<!-- Shared utilities: theme manager applies before first paint, error
|
|
8
|
+
boundary surfaces silent script failures, i18n + api helpers are
|
|
9
|
+
used by the page-specific script below. -->
|
|
10
|
+
<script src="/_app.js"></script>
|
|
11
|
+
<script>
|
|
12
|
+
// Wrapped in an IIFE so `const T` / `LANGS` / `savedLang` / `browserLang`
|
|
13
|
+
// stay out of the global script scope. Without this, the second
|
|
14
|
+
// <script> tag below also declares `const T = window.__t`, and classic
|
|
15
|
+
// (non-module) `<script>` tags share the same top-level lexical scope —
|
|
16
|
+
// which made the page silently die with "Identifier 'T' has already
|
|
17
|
+
// been declared" before any tab labels / buttons could be filled.
|
|
18
|
+
(() => {
|
|
19
|
+
const LANGS = { en: 'English', zh: '中文' };
|
|
20
|
+
const savedLang = localStorage.getItem('im-hub-lang');
|
|
21
|
+
const browserLang = navigator.language.startsWith('zh') ? 'zh' : 'en';
|
|
22
|
+
window.__lang = savedLang && LANGS[savedLang] ? savedLang : browserLang;
|
|
23
|
+
document.documentElement.lang = window.__lang === 'zh' ? 'zh-CN' : 'en';
|
|
24
|
+
|
|
25
|
+
const T = {
|
|
26
|
+
en: {
|
|
27
|
+
title: 'im-hub-pro — Tasks',
|
|
28
|
+
h1: 'Tasks & Schedules',
|
|
29
|
+
backToChat: 'Chat',
|
|
30
|
+
toSettings: 'Settings',
|
|
31
|
+
tabsJobs: 'Jobs',
|
|
32
|
+
tabsBackground: 'Background',
|
|
33
|
+
tabsSubtasks: 'Subtasks',
|
|
34
|
+
tabsSchedules: 'Schedules',
|
|
35
|
+
tabsAudit: 'Audit',
|
|
36
|
+
tabsHealth: 'Health',
|
|
37
|
+
tabsApprovals: 'Approvals',
|
|
38
|
+
loading: 'Loading...',
|
|
39
|
+
// Health tab
|
|
40
|
+
healthBreaker: 'Breaker',
|
|
41
|
+
healthRate: 'Rate limit',
|
|
42
|
+
healthLatency: 'Latency',
|
|
43
|
+
healthInvocations: 'Invocations',
|
|
44
|
+
healthSuccessRate: 'Success rate',
|
|
45
|
+
healthCost: 'Cost',
|
|
46
|
+
healthCooldown: 'Cooldown',
|
|
47
|
+
healthEmpty: 'No agents registered yet.',
|
|
48
|
+
healthSparklineLabel: 'p95 latency over last 60 polls',
|
|
49
|
+
breakerClosed: 'Closed',
|
|
50
|
+
breakerOpen: 'Open',
|
|
51
|
+
breakerHalfOpen: 'Half-open',
|
|
52
|
+
// Approvals tab
|
|
53
|
+
approvalsEmpty: 'No pending approvals.',
|
|
54
|
+
approvalsCount: '{n} pending',
|
|
55
|
+
approvalsAge: 'Age',
|
|
56
|
+
approvalsTool: 'Tool',
|
|
57
|
+
approvalsThread: 'Thread',
|
|
58
|
+
approvalsAllow: 'Allow',
|
|
59
|
+
approvalsDeny: 'Deny',
|
|
60
|
+
approvalsAllowAll: 'Allow + auto',
|
|
61
|
+
approvalsResolveErr: 'Failed to resolve',
|
|
62
|
+
approvalsAutoMode: 'auto-allow grace mode',
|
|
63
|
+
statsTotal: 'Total',
|
|
64
|
+
statsPending: 'Pending',
|
|
65
|
+
statsRunning: 'Running',
|
|
66
|
+
statsCompleted: 'Completed',
|
|
67
|
+
statsFailed: 'Failed',
|
|
68
|
+
filterAll: 'All',
|
|
69
|
+
filterPending: 'Pending',
|
|
70
|
+
filterRunning: 'Running',
|
|
71
|
+
filterCompleted: 'Completed',
|
|
72
|
+
filterFailed: 'Failed',
|
|
73
|
+
filterCancelled: 'Cancelled',
|
|
74
|
+
refresh: 'Refresh',
|
|
75
|
+
autoRefresh: 'Auto-refresh',
|
|
76
|
+
newJob: 'New Job',
|
|
77
|
+
agent: 'Agent',
|
|
78
|
+
prompt: 'Prompt',
|
|
79
|
+
status: 'Status',
|
|
80
|
+
created: 'Created',
|
|
81
|
+
completed: 'Completed',
|
|
82
|
+
actions: 'Actions',
|
|
83
|
+
view: 'View',
|
|
84
|
+
run: 'Run',
|
|
85
|
+
cancel: 'Cancel',
|
|
86
|
+
details: 'Details',
|
|
87
|
+
result: 'Result',
|
|
88
|
+
error: 'Error',
|
|
89
|
+
close: 'Close',
|
|
90
|
+
empty: 'No tasks. Create one with /job create or via the New Job button.',
|
|
91
|
+
emptySchedules: 'No schedules. Create one in IM with /schedule create.',
|
|
92
|
+
scheduleName: 'Name',
|
|
93
|
+
scheduleCron: 'Cron',
|
|
94
|
+
scheduleNext: 'Next run',
|
|
95
|
+
scheduleLast: 'Last run',
|
|
96
|
+
promptCreate: 'Prompt for the new job',
|
|
97
|
+
agentCreate: 'Agent (defaults to claude-code)',
|
|
98
|
+
bgRoot: 'Source',
|
|
99
|
+
bgName: 'Name',
|
|
100
|
+
bgStarted: 'Started',
|
|
101
|
+
bgEnded: 'Ended',
|
|
102
|
+
bgPid: 'PID',
|
|
103
|
+
bgExit: 'Exit',
|
|
104
|
+
bgRestart: 'Gen',
|
|
105
|
+
bgCommand: 'Command',
|
|
106
|
+
bgWorkdir: 'Workdir',
|
|
107
|
+
bgOutDir: 'Output',
|
|
108
|
+
bgLogTail: 'Log tail (last 200 lines)',
|
|
109
|
+
bgEmpty: 'No background jobs in this source. Use bgjob start to create one.',
|
|
110
|
+
subParent: 'Parent thread',
|
|
111
|
+
subAgent: 'Agent',
|
|
112
|
+
subPrompt: 'Prompt',
|
|
113
|
+
subCreated: 'Created',
|
|
114
|
+
subEmpty: 'No subtasks recorded. Use /job switch <id> in IM to spawn one.',
|
|
115
|
+
auditDays: 'Days',
|
|
116
|
+
auditTime: 'Time',
|
|
117
|
+
auditUser: 'User',
|
|
118
|
+
auditPlatform: 'Platform',
|
|
119
|
+
auditIntent: 'Intent',
|
|
120
|
+
auditDuration: 'Duration',
|
|
121
|
+
auditCost: 'Cost',
|
|
122
|
+
auditOk: 'OK',
|
|
123
|
+
auditError: 'Error',
|
|
124
|
+
auditEmpty: 'No invocations recorded yet for this filter.',
|
|
125
|
+
// Files tab — read-only browser of ~/.im-hub-workspaces/<agent>/
|
|
126
|
+
tabsFiles: 'Files',
|
|
127
|
+
filesAgent: 'Agent',
|
|
128
|
+
filesPath: 'Path',
|
|
129
|
+
filesEmpty: 'Empty directory.',
|
|
130
|
+
filesNoAgent: 'No agents registered yet.',
|
|
131
|
+
filesUp: '⬆ up',
|
|
132
|
+
filesRoot: '(root)',
|
|
133
|
+
filesSize: 'Size',
|
|
134
|
+
filesMtime: 'Modified',
|
|
135
|
+
filesBinary: 'Binary file — content shown as base64.',
|
|
136
|
+
filesTruncated: 'File too large — showing first 1 MiB.',
|
|
137
|
+
filesNotFound: 'Not found.',
|
|
138
|
+
filesLoad: 'Load',
|
|
139
|
+
filesEdit: 'Edit',
|
|
140
|
+
filesEditing: 'editing…',
|
|
141
|
+
filesSave: 'Save',
|
|
142
|
+
filesSaving: 'Saving…',
|
|
143
|
+
filesCancel: 'Cancel',
|
|
144
|
+
filesSaved: 'Saved.',
|
|
145
|
+
filesSaveFailed: 'Save failed',
|
|
146
|
+
// Jobs batch toolbar
|
|
147
|
+
jobsSelectAll: 'Select all',
|
|
148
|
+
jobsBatchCancel: 'Cancel selected',
|
|
149
|
+
jobsBatchRun: 'Run selected',
|
|
150
|
+
jobsBatchEmpty: 'No jobs selected.',
|
|
151
|
+
jobsBatchResult: 'Batch result: {ok} ok, {fail} failed',
|
|
152
|
+
},
|
|
153
|
+
zh: {
|
|
154
|
+
title: 'im-hub-pro — 任务',
|
|
155
|
+
h1: '任务与定时',
|
|
156
|
+
backToChat: '对话',
|
|
157
|
+
toSettings: '设置',
|
|
158
|
+
tabsAudit: '审计',
|
|
159
|
+
tabsJobs: '任务',
|
|
160
|
+
tabsBackground: '后台脚本',
|
|
161
|
+
tabsSubtasks: '子任务',
|
|
162
|
+
tabsSchedules: '定时',
|
|
163
|
+
tabsHealth: '健康',
|
|
164
|
+
tabsApprovals: '审批',
|
|
165
|
+
loading: '加载中...',
|
|
166
|
+
// Health tab
|
|
167
|
+
healthBreaker: '断路器',
|
|
168
|
+
healthRate: '限流',
|
|
169
|
+
healthLatency: '延迟',
|
|
170
|
+
healthInvocations: '调用次数',
|
|
171
|
+
healthSuccessRate: '成功率',
|
|
172
|
+
healthCost: '成本',
|
|
173
|
+
healthCooldown: '冷却',
|
|
174
|
+
healthEmpty: '尚未注册任何 Agent。',
|
|
175
|
+
healthSparklineLabel: '最近 60 次轮询的 p95 延迟',
|
|
176
|
+
breakerClosed: '正常',
|
|
177
|
+
breakerOpen: '熔断',
|
|
178
|
+
breakerHalfOpen: '半开',
|
|
179
|
+
// Approvals tab
|
|
180
|
+
approvalsEmpty: '当前无待审批请求。',
|
|
181
|
+
approvalsCount: '待审批 {n} 条',
|
|
182
|
+
approvalsAge: '等待',
|
|
183
|
+
approvalsTool: '工具',
|
|
184
|
+
approvalsThread: '会话',
|
|
185
|
+
approvalsAllow: '批准',
|
|
186
|
+
approvalsDeny: '拒绝',
|
|
187
|
+
approvalsAllowAll: '批准并自动放行',
|
|
188
|
+
approvalsResolveErr: '处理失败',
|
|
189
|
+
approvalsAutoMode: '自动放行宽限期',
|
|
190
|
+
statsTotal: '总计',
|
|
191
|
+
statsPending: '待执行',
|
|
192
|
+
statsRunning: '运行中',
|
|
193
|
+
statsCompleted: '已完成',
|
|
194
|
+
statsFailed: '失败',
|
|
195
|
+
filterAll: '全部',
|
|
196
|
+
filterPending: '待执行',
|
|
197
|
+
filterRunning: '运行中',
|
|
198
|
+
filterCompleted: '已完成',
|
|
199
|
+
filterFailed: '失败',
|
|
200
|
+
filterCancelled: '已取消',
|
|
201
|
+
refresh: '刷新',
|
|
202
|
+
autoRefresh: '自动刷新',
|
|
203
|
+
newJob: '新建任务',
|
|
204
|
+
agent: 'Agent',
|
|
205
|
+
prompt: '内容',
|
|
206
|
+
status: '状态',
|
|
207
|
+
created: '创建',
|
|
208
|
+
completed: '完成',
|
|
209
|
+
actions: '操作',
|
|
210
|
+
view: '查看',
|
|
211
|
+
run: '运行',
|
|
212
|
+
cancel: '取消',
|
|
213
|
+
details: '详情',
|
|
214
|
+
result: '结果',
|
|
215
|
+
error: '错误',
|
|
216
|
+
close: '关闭',
|
|
217
|
+
empty: '暂无任务。可在 IM 内 /job create 或点"新建任务"。',
|
|
218
|
+
emptySchedules: '暂无定时任务。在 IM 内 /schedule create 创建。',
|
|
219
|
+
scheduleName: '名称',
|
|
220
|
+
scheduleCron: 'Cron',
|
|
221
|
+
scheduleNext: '下次',
|
|
222
|
+
scheduleLast: '上次',
|
|
223
|
+
promptCreate: '输入任务内容',
|
|
224
|
+
agentCreate: 'Agent (默认 claude-code)',
|
|
225
|
+
bgRoot: '来源',
|
|
226
|
+
bgName: '名称',
|
|
227
|
+
bgStarted: '启动',
|
|
228
|
+
bgEnded: '结束',
|
|
229
|
+
bgPid: 'PID',
|
|
230
|
+
bgExit: '退出码',
|
|
231
|
+
bgRestart: '代次',
|
|
232
|
+
bgCommand: '命令',
|
|
233
|
+
bgWorkdir: '工作目录',
|
|
234
|
+
bgOutDir: '输出目录',
|
|
235
|
+
bgLogTail: '日志尾部(最后 200 行)',
|
|
236
|
+
bgEmpty: '该来源下暂无后台任务。可在终端 bgjob start 启动。',
|
|
237
|
+
subParent: '所属会话',
|
|
238
|
+
subAgent: 'Agent',
|
|
239
|
+
subPrompt: '内容',
|
|
240
|
+
subCreated: '创建',
|
|
241
|
+
subEmpty: '暂无子任务。在 IM 内 /job switch <id> 可派生。',
|
|
242
|
+
auditDays: '天数',
|
|
243
|
+
auditTime: '时间',
|
|
244
|
+
auditUser: '用户',
|
|
245
|
+
auditPlatform: '平台',
|
|
246
|
+
auditIntent: '意图',
|
|
247
|
+
auditDuration: '耗时',
|
|
248
|
+
auditCost: '成本',
|
|
249
|
+
auditOk: '成功',
|
|
250
|
+
auditError: '错误',
|
|
251
|
+
auditEmpty: '当前过滤下暂无调用记录。',
|
|
252
|
+
// Files tab — read-only browser of ~/.im-hub-workspaces/<agent>/
|
|
253
|
+
tabsFiles: '工作区',
|
|
254
|
+
filesAgent: 'Agent',
|
|
255
|
+
filesPath: '路径',
|
|
256
|
+
filesEmpty: '空目录。',
|
|
257
|
+
filesNoAgent: '尚未注册任何 Agent。',
|
|
258
|
+
filesUp: '⬆ 上一级',
|
|
259
|
+
filesRoot: '(根)',
|
|
260
|
+
filesSize: '大小',
|
|
261
|
+
filesMtime: '修改时间',
|
|
262
|
+
filesBinary: '二进制文件 — 以 base64 显示。',
|
|
263
|
+
filesTruncated: '文件过大 — 仅显示前 1 MiB。',
|
|
264
|
+
filesNotFound: '未找到。',
|
|
265
|
+
filesLoad: '加载',
|
|
266
|
+
filesEdit: '编辑',
|
|
267
|
+
filesEditing: '编辑中…',
|
|
268
|
+
filesSave: '保存',
|
|
269
|
+
filesSaving: '保存中…',
|
|
270
|
+
filesCancel: '取消',
|
|
271
|
+
filesSaved: '已保存。',
|
|
272
|
+
filesSaveFailed: '保存失败',
|
|
273
|
+
// Jobs batch toolbar
|
|
274
|
+
jobsSelectAll: '全选',
|
|
275
|
+
jobsBatchCancel: '批量取消',
|
|
276
|
+
jobsBatchRun: '批量运行',
|
|
277
|
+
jobsBatchEmpty: '未选择任何任务。',
|
|
278
|
+
jobsBatchResult: '批量结果:成功 {ok},失败 {fail}',
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
window.__t = T[window.__lang];
|
|
282
|
+
})();
|
|
283
|
+
</script>
|
|
284
|
+
<style>
|
|
285
|
+
/* Three-state theming. `:root` is the light default; explicit
|
|
286
|
+
data-theme="light"|"dark" forces a mode regardless of OS pref;
|
|
287
|
+
`prefers-color-scheme: dark` only applies when the attribute is
|
|
288
|
+
absent (i.e. mode === 'system' in _app.js). */
|
|
289
|
+
:root {
|
|
290
|
+
color-scheme: light dark;
|
|
291
|
+
--bg: #fafafa;
|
|
292
|
+
--fg: #222;
|
|
293
|
+
--muted: #666;
|
|
294
|
+
--border: #ddd;
|
|
295
|
+
--primary: #0066cc;
|
|
296
|
+
--success: #28a745;
|
|
297
|
+
--warning: #ffc107;
|
|
298
|
+
--danger: #dc3545;
|
|
299
|
+
--info: #17a2b8;
|
|
300
|
+
--card: #fff;
|
|
301
|
+
}
|
|
302
|
+
:root[data-theme="dark"] {
|
|
303
|
+
--bg: #0e0e10;
|
|
304
|
+
--fg: #e6e6e6;
|
|
305
|
+
--muted: #888;
|
|
306
|
+
--border: #2a2a2e;
|
|
307
|
+
--card: #1a1a1d;
|
|
308
|
+
}
|
|
309
|
+
@media (prefers-color-scheme: dark) {
|
|
310
|
+
:root:not([data-theme]) {
|
|
311
|
+
--bg: #0e0e10;
|
|
312
|
+
--fg: #e6e6e6;
|
|
313
|
+
--muted: #888;
|
|
314
|
+
--border: #2a2a2e;
|
|
315
|
+
--card: #1a1a1d;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
* { box-sizing: border-box; }
|
|
319
|
+
body {
|
|
320
|
+
margin: 0;
|
|
321
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
322
|
+
background: var(--bg);
|
|
323
|
+
color: var(--fg);
|
|
324
|
+
font-size: 14px;
|
|
325
|
+
}
|
|
326
|
+
header {
|
|
327
|
+
display: flex;
|
|
328
|
+
align-items: center;
|
|
329
|
+
gap: 16px;
|
|
330
|
+
padding: 14px 24px;
|
|
331
|
+
border-bottom: 1px solid var(--border);
|
|
332
|
+
background: var(--card);
|
|
333
|
+
}
|
|
334
|
+
header h1 { margin: 0; font-size: 18px; flex: 1; }
|
|
335
|
+
header a, header button {
|
|
336
|
+
color: var(--primary);
|
|
337
|
+
text-decoration: none;
|
|
338
|
+
font-size: 14px;
|
|
339
|
+
background: none;
|
|
340
|
+
border: 1px solid var(--border);
|
|
341
|
+
padding: 6px 12px;
|
|
342
|
+
border-radius: 4px;
|
|
343
|
+
cursor: pointer;
|
|
344
|
+
}
|
|
345
|
+
header a:hover, header button:hover { border-color: var(--primary); }
|
|
346
|
+
main { padding: 20px 24px; max-width: 1200px; margin: 0 auto; }
|
|
347
|
+
.tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border); margin-bottom: 20px; }
|
|
348
|
+
.tab {
|
|
349
|
+
padding: 8px 16px;
|
|
350
|
+
cursor: pointer;
|
|
351
|
+
border: none;
|
|
352
|
+
background: none;
|
|
353
|
+
color: var(--muted);
|
|
354
|
+
font-size: 14px;
|
|
355
|
+
border-bottom: 2px solid transparent;
|
|
356
|
+
}
|
|
357
|
+
.tab.active { color: var(--fg); border-bottom-color: var(--primary); }
|
|
358
|
+
.stats {
|
|
359
|
+
display: grid;
|
|
360
|
+
grid-template-columns: repeat(5, 1fr);
|
|
361
|
+
gap: 12px;
|
|
362
|
+
margin-bottom: 16px;
|
|
363
|
+
}
|
|
364
|
+
.stat {
|
|
365
|
+
background: var(--card);
|
|
366
|
+
padding: 12px 16px;
|
|
367
|
+
border-radius: 6px;
|
|
368
|
+
border: 1px solid var(--border);
|
|
369
|
+
}
|
|
370
|
+
.stat-label { color: var(--muted); font-size: 12px; }
|
|
371
|
+
.stat-val { font-size: 22px; font-weight: 600; margin-top: 4px; }
|
|
372
|
+
.toolbar {
|
|
373
|
+
display: flex;
|
|
374
|
+
gap: 8px;
|
|
375
|
+
align-items: center;
|
|
376
|
+
margin-bottom: 12px;
|
|
377
|
+
flex-wrap: wrap;
|
|
378
|
+
}
|
|
379
|
+
.toolbar select, .toolbar button, .toolbar label {
|
|
380
|
+
font-size: 13px;
|
|
381
|
+
padding: 5px 10px;
|
|
382
|
+
border: 1px solid var(--border);
|
|
383
|
+
background: var(--card);
|
|
384
|
+
color: var(--fg);
|
|
385
|
+
border-radius: 4px;
|
|
386
|
+
}
|
|
387
|
+
.toolbar button { cursor: pointer; }
|
|
388
|
+
.toolbar button.primary { background: var(--primary); color: #fff; border-color: var(--primary); }
|
|
389
|
+
.toolbar label { display: flex; align-items: center; gap: 6px; cursor: pointer; }
|
|
390
|
+
table {
|
|
391
|
+
width: 100%;
|
|
392
|
+
border-collapse: collapse;
|
|
393
|
+
background: var(--card);
|
|
394
|
+
border-radius: 6px;
|
|
395
|
+
overflow: hidden;
|
|
396
|
+
border: 1px solid var(--border);
|
|
397
|
+
}
|
|
398
|
+
th, td {
|
|
399
|
+
text-align: left;
|
|
400
|
+
padding: 10px 12px;
|
|
401
|
+
font-size: 13px;
|
|
402
|
+
border-bottom: 1px solid var(--border);
|
|
403
|
+
}
|
|
404
|
+
th {
|
|
405
|
+
background: var(--bg);
|
|
406
|
+
color: var(--muted);
|
|
407
|
+
font-weight: 500;
|
|
408
|
+
font-size: 12px;
|
|
409
|
+
text-transform: uppercase;
|
|
410
|
+
}
|
|
411
|
+
tbody tr:last-child td { border-bottom: none; }
|
|
412
|
+
.pill {
|
|
413
|
+
display: inline-block;
|
|
414
|
+
padding: 2px 8px;
|
|
415
|
+
border-radius: 999px;
|
|
416
|
+
font-size: 11px;
|
|
417
|
+
font-weight: 500;
|
|
418
|
+
}
|
|
419
|
+
.pill.pending { background: rgba(255, 193, 7, 0.18); color: #b89000; }
|
|
420
|
+
.pill.running { background: rgba(23, 162, 184, 0.18); color: #0d8898; }
|
|
421
|
+
.pill.completed { background: rgba(40, 167, 69, 0.18); color: #1e7c34; }
|
|
422
|
+
.pill.failed { background: rgba(220, 53, 69, 0.18); color: #b32433; }
|
|
423
|
+
.pill.cancelled { background: rgba(108, 117, 125, 0.18); color: #5a6268; }
|
|
424
|
+
.row-actions button {
|
|
425
|
+
font-size: 12px;
|
|
426
|
+
padding: 3px 8px;
|
|
427
|
+
margin-right: 4px;
|
|
428
|
+
cursor: pointer;
|
|
429
|
+
border: 1px solid var(--border);
|
|
430
|
+
background: var(--card);
|
|
431
|
+
color: var(--fg);
|
|
432
|
+
border-radius: 4px;
|
|
433
|
+
}
|
|
434
|
+
.row-actions button:hover { border-color: var(--primary); color: var(--primary); }
|
|
435
|
+
.row-actions button.danger:hover { border-color: var(--danger); color: var(--danger); }
|
|
436
|
+
.empty { text-align: center; padding: 40px; color: var(--muted); }
|
|
437
|
+
.modal-bg {
|
|
438
|
+
position: fixed; inset: 0;
|
|
439
|
+
background: rgba(0,0,0,0.5);
|
|
440
|
+
display: none;
|
|
441
|
+
align-items: center;
|
|
442
|
+
justify-content: center;
|
|
443
|
+
z-index: 100;
|
|
444
|
+
}
|
|
445
|
+
.modal-bg.show { display: flex; }
|
|
446
|
+
.modal {
|
|
447
|
+
background: var(--card);
|
|
448
|
+
border-radius: 8px;
|
|
449
|
+
padding: 20px;
|
|
450
|
+
max-width: 720px;
|
|
451
|
+
width: 90%;
|
|
452
|
+
max-height: 80vh;
|
|
453
|
+
overflow-y: auto;
|
|
454
|
+
}
|
|
455
|
+
.modal h2 { margin-top: 0; }
|
|
456
|
+
.modal pre {
|
|
457
|
+
background: var(--bg);
|
|
458
|
+
padding: 12px;
|
|
459
|
+
border-radius: 6px;
|
|
460
|
+
overflow-x: auto;
|
|
461
|
+
max-height: 400px;
|
|
462
|
+
white-space: pre-wrap;
|
|
463
|
+
font-size: 12px;
|
|
464
|
+
}
|
|
465
|
+
.modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; }
|
|
466
|
+
.modal input, .modal textarea, .modal select {
|
|
467
|
+
width: 100%;
|
|
468
|
+
padding: 8px;
|
|
469
|
+
margin: 4px 0 12px;
|
|
470
|
+
background: var(--bg);
|
|
471
|
+
color: var(--fg);
|
|
472
|
+
border: 1px solid var(--border);
|
|
473
|
+
border-radius: 4px;
|
|
474
|
+
font: inherit;
|
|
475
|
+
}
|
|
476
|
+
.modal textarea { min-height: 100px; resize: vertical; }
|
|
477
|
+
code { font-family: 'SF Mono', Menlo, Consolas, monospace; font-size: 12px; }
|
|
478
|
+
.truncate { max-width: 360px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
479
|
+
|
|
480
|
+
/* PR-B: Health tab — per-agent operational card */
|
|
481
|
+
.health-card {
|
|
482
|
+
background: var(--card);
|
|
483
|
+
border: 1px solid var(--border);
|
|
484
|
+
border-radius: 8px;
|
|
485
|
+
padding: 14px 16px;
|
|
486
|
+
margin-bottom: 12px;
|
|
487
|
+
}
|
|
488
|
+
.health-head {
|
|
489
|
+
display: flex;
|
|
490
|
+
align-items: center;
|
|
491
|
+
gap: 10px;
|
|
492
|
+
margin-bottom: 12px;
|
|
493
|
+
}
|
|
494
|
+
.health-agent { font-size: 14px; font-weight: 600; }
|
|
495
|
+
.health-grid {
|
|
496
|
+
display: grid;
|
|
497
|
+
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
|
498
|
+
gap: 12px 20px;
|
|
499
|
+
}
|
|
500
|
+
.health-label { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.4px; }
|
|
501
|
+
.health-val { font-size: 13px; margin-top: 3px; font-variant-numeric: tabular-nums; }
|
|
502
|
+
.health-spark { margin-top: 3px; }
|
|
503
|
+
|
|
504
|
+
/* PR-B: Approvals tab — global pending list */
|
|
505
|
+
.approval-item {
|
|
506
|
+
background: var(--card);
|
|
507
|
+
border: 1px solid var(--border);
|
|
508
|
+
border-left: 3px solid var(--primary);
|
|
509
|
+
border-radius: 6px;
|
|
510
|
+
padding: 12px 14px;
|
|
511
|
+
margin-bottom: 10px;
|
|
512
|
+
}
|
|
513
|
+
.approval-head {
|
|
514
|
+
display: flex;
|
|
515
|
+
align-items: center;
|
|
516
|
+
flex-wrap: wrap;
|
|
517
|
+
gap: 8px;
|
|
518
|
+
margin-bottom: 8px;
|
|
519
|
+
}
|
|
520
|
+
.approval-head code { font-size: 13px; font-weight: 600; }
|
|
521
|
+
.approval-meta {
|
|
522
|
+
margin-left: auto;
|
|
523
|
+
font-size: 12px;
|
|
524
|
+
color: var(--muted);
|
|
525
|
+
}
|
|
526
|
+
.approval-input {
|
|
527
|
+
background: var(--bg);
|
|
528
|
+
border: 1px solid var(--border);
|
|
529
|
+
border-radius: 4px;
|
|
530
|
+
padding: 8px 10px;
|
|
531
|
+
font: 12px/1.5 'SF Mono', Menlo, Consolas, monospace;
|
|
532
|
+
max-height: 180px;
|
|
533
|
+
overflow: auto;
|
|
534
|
+
margin: 6px 0 10px;
|
|
535
|
+
white-space: pre-wrap;
|
|
536
|
+
word-break: break-word;
|
|
537
|
+
}
|
|
538
|
+
.approval-actions { display: flex; gap: 8px; }
|
|
539
|
+
.approval-actions button {
|
|
540
|
+
border: 1px solid var(--border);
|
|
541
|
+
background: var(--card);
|
|
542
|
+
color: var(--fg);
|
|
543
|
+
padding: 5px 14px;
|
|
544
|
+
border-radius: 4px;
|
|
545
|
+
cursor: pointer;
|
|
546
|
+
font-size: 13px;
|
|
547
|
+
}
|
|
548
|
+
.approval-actions button:hover { border-color: var(--primary); }
|
|
549
|
+
.approval-actions button:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
550
|
+
.approval-actions .btn-allow { background: var(--success); border-color: var(--success); color: #fff; }
|
|
551
|
+
.approval-actions .btn-deny { background: var(--danger); border-color: var(--danger); color: #fff; }
|
|
552
|
+
</style>
|
|
553
|
+
</head>
|
|
554
|
+
<body>
|
|
555
|
+
<header>
|
|
556
|
+
<h1 id="page-title"></h1>
|
|
557
|
+
<button id="theme-toggle" type="button" aria-label="Toggle color theme"></button>
|
|
558
|
+
<a href="/">↩ <span id="lbl-chat"></span></a>
|
|
559
|
+
<a href="/settings"><span id="lbl-settings"></span></a>
|
|
560
|
+
</header>
|
|
561
|
+
<main>
|
|
562
|
+
<div class="tabs">
|
|
563
|
+
<button class="tab active" data-tab="jobs" id="tab-jobs"></button>
|
|
564
|
+
<button class="tab" data-tab="background" id="tab-background"></button>
|
|
565
|
+
<button class="tab" data-tab="subtasks" id="tab-subtasks"></button>
|
|
566
|
+
<button class="tab" data-tab="schedules" id="tab-schedules"></button>
|
|
567
|
+
<button class="tab" data-tab="approvals" id="tab-approvals"></button>
|
|
568
|
+
<button class="tab" data-tab="health" id="tab-health"></button>
|
|
569
|
+
<button class="tab" data-tab="files" id="tab-files"></button>
|
|
570
|
+
<button class="tab" data-tab="audit" id="tab-audit"></button>
|
|
571
|
+
</div>
|
|
572
|
+
|
|
573
|
+
<section id="jobs-pane">
|
|
574
|
+
<div class="stats" id="stats"></div>
|
|
575
|
+
<div class="toolbar">
|
|
576
|
+
<select id="filter-status">
|
|
577
|
+
<option value=""></option>
|
|
578
|
+
<option value="pending"></option>
|
|
579
|
+
<option value="running"></option>
|
|
580
|
+
<option value="completed"></option>
|
|
581
|
+
<option value="failed"></option>
|
|
582
|
+
<option value="cancelled"></option>
|
|
583
|
+
</select>
|
|
584
|
+
<select id="jobs-agent-filter" data-agent-filter>
|
|
585
|
+
<option value="">All agents</option>
|
|
586
|
+
<option value="claude-code">claude-code</option>
|
|
587
|
+
<option value="opencode">opencode</option>
|
|
588
|
+
<option value="codex">codex</option>
|
|
589
|
+
</select>
|
|
590
|
+
<button id="btn-refresh"></button>
|
|
591
|
+
<label><input type="checkbox" id="auto-refresh" checked> <span id="lbl-auto"></span></label>
|
|
592
|
+
<button id="btn-new" class="primary"></button>
|
|
593
|
+
</div>
|
|
594
|
+
<div class="toolbar" id="batch-toolbar" style="display:none">
|
|
595
|
+
<span id="batch-summary" style="color:var(--muted);font-size:13px"></span>
|
|
596
|
+
<button id="btn-batch-run"></button>
|
|
597
|
+
<button id="btn-batch-cancel" class="danger"></button>
|
|
598
|
+
</div>
|
|
599
|
+
<div id="jobs-list"></div>
|
|
600
|
+
</section>
|
|
601
|
+
|
|
602
|
+
<section id="background-pane" hidden>
|
|
603
|
+
<div class="toolbar">
|
|
604
|
+
<select id="bg-root-filter"></select>
|
|
605
|
+
<button id="btn-bg-refresh"></button>
|
|
606
|
+
<label><input type="checkbox" id="bg-auto-refresh" checked> <span id="lbl-bg-auto"></span></label>
|
|
607
|
+
</div>
|
|
608
|
+
<div id="bg-list"></div>
|
|
609
|
+
</section>
|
|
610
|
+
|
|
611
|
+
<section id="subtasks-pane" hidden>
|
|
612
|
+
<div class="toolbar">
|
|
613
|
+
<select id="subtasks-agent-filter" data-agent-filter>
|
|
614
|
+
<option value="">All agents</option>
|
|
615
|
+
<option value="claude-code">claude-code</option>
|
|
616
|
+
<option value="opencode">opencode</option>
|
|
617
|
+
<option value="codex">codex</option>
|
|
618
|
+
</select>
|
|
619
|
+
<button id="btn-sub-refresh"></button>
|
|
620
|
+
</div>
|
|
621
|
+
<div id="sub-list"></div>
|
|
622
|
+
</section>
|
|
623
|
+
|
|
624
|
+
<section id="schedules-pane" hidden>
|
|
625
|
+
<div class="toolbar">
|
|
626
|
+
<select id="schedules-agent-filter" data-agent-filter>
|
|
627
|
+
<option value="">All agents</option>
|
|
628
|
+
<option value="claude-code">claude-code</option>
|
|
629
|
+
<option value="opencode">opencode</option>
|
|
630
|
+
<option value="codex">codex</option>
|
|
631
|
+
</select>
|
|
632
|
+
<button id="btn-sched-refresh">Refresh</button>
|
|
633
|
+
</div>
|
|
634
|
+
<div id="schedules-list"></div>
|
|
635
|
+
</section>
|
|
636
|
+
|
|
637
|
+
<section id="approvals-pane" hidden>
|
|
638
|
+
<div class="toolbar">
|
|
639
|
+
<button id="btn-approvals-refresh">Refresh</button>
|
|
640
|
+
<label><input type="checkbox" id="approvals-auto-refresh" checked> <span id="lbl-approvals-auto"></span></label>
|
|
641
|
+
<span id="approvals-summary" style="margin-left:auto;color:var(--muted);font-size:13px"></span>
|
|
642
|
+
</div>
|
|
643
|
+
<div id="approvals-list"></div>
|
|
644
|
+
</section>
|
|
645
|
+
|
|
646
|
+
<section id="health-pane" hidden>
|
|
647
|
+
<div class="toolbar">
|
|
648
|
+
<button id="btn-health-refresh">Refresh</button>
|
|
649
|
+
<label><input type="checkbox" id="health-auto-refresh" checked> <span id="lbl-health-auto"></span></label>
|
|
650
|
+
<span id="health-uptime" style="margin-left:auto;color:var(--muted);font-size:13px"></span>
|
|
651
|
+
</div>
|
|
652
|
+
<div id="health-list"></div>
|
|
653
|
+
</section>
|
|
654
|
+
|
|
655
|
+
<section id="files-pane" hidden>
|
|
656
|
+
<div class="toolbar">
|
|
657
|
+
<label><span id="lbl-files-agent"></span>
|
|
658
|
+
<select id="files-agent" data-agent-filter></select>
|
|
659
|
+
</label>
|
|
660
|
+
<button id="btn-files-up"></button>
|
|
661
|
+
<code id="files-current" style="color:var(--muted);font-size:13px"></code>
|
|
662
|
+
<button id="btn-files-refresh" style="margin-left:auto"></button>
|
|
663
|
+
</div>
|
|
664
|
+
<div class="files-layout" style="display:flex;gap:12px;align-items:flex-start">
|
|
665
|
+
<div id="files-tree" style="flex:0 0 320px;max-height:60vh;overflow:auto"></div>
|
|
666
|
+
<div id="files-content" style="flex:1 1 auto;min-width:0"></div>
|
|
667
|
+
</div>
|
|
668
|
+
</section>
|
|
669
|
+
|
|
670
|
+
<section id="audit-pane" hidden>
|
|
671
|
+
<div class="toolbar">
|
|
672
|
+
<select id="audit-agent-filter" data-agent-filter>
|
|
673
|
+
<option value="">All agents</option>
|
|
674
|
+
<option value="claude-code">claude-code</option>
|
|
675
|
+
<option value="opencode">opencode</option>
|
|
676
|
+
<option value="codex">codex</option>
|
|
677
|
+
</select>
|
|
678
|
+
<select id="audit-days-filter">
|
|
679
|
+
<option value="1">1d</option>
|
|
680
|
+
<option value="7" selected>7d</option>
|
|
681
|
+
<option value="30">30d</option>
|
|
682
|
+
<option value="90">90d</option>
|
|
683
|
+
</select>
|
|
684
|
+
<input type="text" id="audit-user-filter" placeholder="user id (optional)" style="min-width:160px">
|
|
685
|
+
<button id="btn-audit-refresh">Refresh</button>
|
|
686
|
+
</div>
|
|
687
|
+
<div class="stats" id="audit-stats"></div>
|
|
688
|
+
<div id="audit-list"></div>
|
|
689
|
+
</section>
|
|
690
|
+
</main>
|
|
691
|
+
|
|
692
|
+
<div class="modal-bg" id="modal-bg">
|
|
693
|
+
<div class="modal" id="modal"></div>
|
|
694
|
+
</div>
|
|
695
|
+
|
|
696
|
+
<script>
|
|
697
|
+
const T = window.__t;
|
|
698
|
+
const TOKEN = window.IMHUB_TOKEN;
|
|
699
|
+
|
|
700
|
+
// Theme toggle (light / dark / system). _app.js applied the theme
|
|
701
|
+
// synchronously in <head>; here we wire the button so clicks cycle
|
|
702
|
+
// and the label re-renders.
|
|
703
|
+
if (window.imhub) imhub.theme.bindToggle(document.getElementById('theme-toggle'));
|
|
704
|
+
|
|
705
|
+
// i18n string fills
|
|
706
|
+
document.title = T.title;
|
|
707
|
+
document.getElementById('page-title').textContent = T.h1;
|
|
708
|
+
document.getElementById('lbl-chat').textContent = T.backToChat;
|
|
709
|
+
document.getElementById('lbl-settings').textContent = T.toSettings;
|
|
710
|
+
document.getElementById('tab-jobs').textContent = T.tabsJobs;
|
|
711
|
+
document.getElementById('tab-background').textContent = T.tabsBackground;
|
|
712
|
+
document.getElementById('tab-subtasks').textContent = T.tabsSubtasks;
|
|
713
|
+
document.getElementById('tab-schedules').textContent = T.tabsSchedules;
|
|
714
|
+
document.getElementById('tab-audit').textContent = T.tabsAudit;
|
|
715
|
+
document.getElementById('tab-approvals').textContent = T.tabsApprovals;
|
|
716
|
+
document.getElementById('tab-health').textContent = T.tabsHealth;
|
|
717
|
+
document.getElementById('tab-files').textContent = T.tabsFiles;
|
|
718
|
+
document.getElementById('btn-bg-refresh').textContent = T.refresh;
|
|
719
|
+
document.getElementById('btn-sub-refresh').textContent = T.refresh;
|
|
720
|
+
document.getElementById('btn-approvals-refresh').textContent = T.refresh;
|
|
721
|
+
document.getElementById('btn-health-refresh').textContent = T.refresh;
|
|
722
|
+
document.getElementById('lbl-bg-auto').textContent = T.autoRefresh;
|
|
723
|
+
document.getElementById('lbl-approvals-auto').textContent = T.autoRefresh;
|
|
724
|
+
document.getElementById('lbl-health-auto').textContent = T.autoRefresh;
|
|
725
|
+
document.getElementById('btn-refresh').textContent = T.refresh;
|
|
726
|
+
document.getElementById('btn-new').textContent = T.newJob;
|
|
727
|
+
document.getElementById('lbl-auto').textContent = T.autoRefresh;
|
|
728
|
+
document.getElementById('lbl-files-agent').textContent = T.filesAgent;
|
|
729
|
+
document.getElementById('btn-files-up').textContent = T.filesUp;
|
|
730
|
+
document.getElementById('btn-files-refresh').textContent = T.refresh;
|
|
731
|
+
document.getElementById('btn-batch-run').textContent = T.jobsBatchRun;
|
|
732
|
+
document.getElementById('btn-batch-cancel').textContent = T.jobsBatchCancel;
|
|
733
|
+
{
|
|
734
|
+
const fs = document.getElementById('filter-status');
|
|
735
|
+
fs.options[0].textContent = T.filterAll;
|
|
736
|
+
fs.options[1].textContent = T.filterPending;
|
|
737
|
+
fs.options[2].textContent = T.filterRunning;
|
|
738
|
+
fs.options[3].textContent = T.filterCompleted;
|
|
739
|
+
fs.options[4].textContent = T.filterFailed;
|
|
740
|
+
fs.options[5].textContent = T.filterCancelled;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// Tab switching
|
|
744
|
+
document.querySelectorAll('.tab').forEach(t => {
|
|
745
|
+
t.onclick = () => {
|
|
746
|
+
document.querySelectorAll('.tab').forEach(x => x.classList.toggle('active', x === t));
|
|
747
|
+
const tab = t.dataset.tab;
|
|
748
|
+
document.getElementById('jobs-pane').hidden = tab !== 'jobs';
|
|
749
|
+
document.getElementById('background-pane').hidden = tab !== 'background';
|
|
750
|
+
document.getElementById('subtasks-pane').hidden = tab !== 'subtasks';
|
|
751
|
+
document.getElementById('schedules-pane').hidden = tab !== 'schedules';
|
|
752
|
+
document.getElementById('audit-pane').hidden = tab !== 'audit';
|
|
753
|
+
document.getElementById('approvals-pane').hidden = tab !== 'approvals';
|
|
754
|
+
document.getElementById('health-pane').hidden = tab !== 'health';
|
|
755
|
+
document.getElementById('files-pane').hidden = tab !== 'files';
|
|
756
|
+
// Lazy-load on first activation; auto-refresh hooks below kick in too.
|
|
757
|
+
if (tab === 'schedules') loadSchedules();
|
|
758
|
+
if (tab === 'background') { ensureBgRootsLoaded().then(loadBgjobs); }
|
|
759
|
+
if (tab === 'subtasks') loadSubtasks();
|
|
760
|
+
if (tab === 'audit') loadAudit();
|
|
761
|
+
if (tab === 'approvals') loadApprovals();
|
|
762
|
+
if (tab === 'health') loadHealth();
|
|
763
|
+
if (tab === 'files') ensureFilesAgentLoaded().then(() => loadFiles(filesPath));
|
|
764
|
+
// Pause/resume auto-refresh so hidden tabs don't poll.
|
|
765
|
+
setupBgAutoRefresh();
|
|
766
|
+
setupApprovalsAutoRefresh();
|
|
767
|
+
setupHealthAutoRefresh();
|
|
768
|
+
};
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
// API helper
|
|
772
|
+
async function api(path, init) {
|
|
773
|
+
const res = await fetch(path, {
|
|
774
|
+
...init,
|
|
775
|
+
headers: { 'X-IM-Hub-Token': TOKEN, 'Content-Type': 'application/json', ...(init && init.headers) },
|
|
776
|
+
});
|
|
777
|
+
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
|
778
|
+
return res.json();
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function fmtTime(s) {
|
|
782
|
+
if (!s) return '-';
|
|
783
|
+
const d = new Date(s.endsWith('Z') ? s : s + 'Z');
|
|
784
|
+
return d.toLocaleString();
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function statusPill(status) {
|
|
788
|
+
return `<span class="pill ${status}">${T['filter' + status.charAt(0).toUpperCase() + status.slice(1)] || status}</span>`;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function renderStats(stats) {
|
|
792
|
+
const el = document.getElementById('stats');
|
|
793
|
+
const fields = [
|
|
794
|
+
['Total', stats.total],
|
|
795
|
+
['Pending', stats.pending],
|
|
796
|
+
['Running', stats.running],
|
|
797
|
+
['Completed', stats.completed],
|
|
798
|
+
['Failed', stats.failed],
|
|
799
|
+
];
|
|
800
|
+
el.innerHTML = fields.map(([k, v]) =>
|
|
801
|
+
`<div class="stat"><div class="stat-label">${T['stats' + k]}</div><div class="stat-val">${v}</div></div>`
|
|
802
|
+
).join('');
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// Selection state for batch ops. Lives outside renderJobs so a refresh
|
|
806
|
+
// after a successful batch run/cancel can reconcile against still-visible
|
|
807
|
+
// rows without forgetting the user's prior selection.
|
|
808
|
+
const jobSelection = new Set();
|
|
809
|
+
|
|
810
|
+
function renderJobs(jobs) {
|
|
811
|
+
const el = document.getElementById('jobs-list');
|
|
812
|
+
if (!jobs.length) {
|
|
813
|
+
el.innerHTML = `<div class="empty">${T.empty}</div>`;
|
|
814
|
+
updateBatchToolbar();
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
// Drop ids that disappeared since last render so the toolbar count
|
|
818
|
+
// stays accurate even after the server sweeps completed jobs.
|
|
819
|
+
const visible = new Set(jobs.map(j => j.id));
|
|
820
|
+
for (const id of Array.from(jobSelection)) {
|
|
821
|
+
if (!visible.has(id)) jobSelection.delete(id);
|
|
822
|
+
}
|
|
823
|
+
const allSelected = jobs.length > 0 && jobs.every(j => jobSelection.has(j.id));
|
|
824
|
+
el.innerHTML = `
|
|
825
|
+
<table>
|
|
826
|
+
<thead>
|
|
827
|
+
<tr>
|
|
828
|
+
<th><input type="checkbox" id="job-select-all" ${allSelected ? 'checked' : ''} title="${T.jobsSelectAll}"></th>
|
|
829
|
+
<th>#</th>
|
|
830
|
+
<th>${T.agent}</th>
|
|
831
|
+
<th>${T.prompt}</th>
|
|
832
|
+
<th>${T.status}</th>
|
|
833
|
+
<th>${T.created}</th>
|
|
834
|
+
<th>${T.actions}</th>
|
|
835
|
+
</tr>
|
|
836
|
+
</thead>
|
|
837
|
+
<tbody>
|
|
838
|
+
${jobs.map(j => `
|
|
839
|
+
<tr data-id="${j.id}">
|
|
840
|
+
<td><input type="checkbox" data-sel="${j.id}" ${jobSelection.has(j.id) ? 'checked' : ''}></td>
|
|
841
|
+
<td>#${j.id}</td>
|
|
842
|
+
<td><code>${j.agent}</code></td>
|
|
843
|
+
<td class="truncate">${esc(j.prompt)}</td>
|
|
844
|
+
<td>${statusPill(j.status)}</td>
|
|
845
|
+
<td>${fmtTime(j.created_at)}</td>
|
|
846
|
+
<td class="row-actions">
|
|
847
|
+
<button data-act="view">${T.view}</button>
|
|
848
|
+
${j.status === 'pending' || j.status === 'completed' || j.status === 'failed'
|
|
849
|
+
? `<button data-act="run">${T.run}</button>` : ''}
|
|
850
|
+
${j.status === 'pending' || j.status === 'running'
|
|
851
|
+
? `<button data-act="cancel" class="danger">${T.cancel}</button>` : ''}
|
|
852
|
+
</td>
|
|
853
|
+
</tr>
|
|
854
|
+
`).join('')}
|
|
855
|
+
</tbody>
|
|
856
|
+
</table>
|
|
857
|
+
`;
|
|
858
|
+
el.querySelectorAll('tr[data-id]').forEach(tr => {
|
|
859
|
+
const id = parseInt(tr.dataset.id, 10);
|
|
860
|
+
tr.querySelector('[data-act="view"]')?.addEventListener('click', () => showJob(id));
|
|
861
|
+
tr.querySelector('[data-act="run"]')?.addEventListener('click', () => runJob(id));
|
|
862
|
+
tr.querySelector('[data-act="cancel"]')?.addEventListener('click', () => cancelJob(id));
|
|
863
|
+
tr.querySelector('[data-sel]')?.addEventListener('change', (e) => {
|
|
864
|
+
if (e.target.checked) jobSelection.add(id);
|
|
865
|
+
else jobSelection.delete(id);
|
|
866
|
+
updateBatchToolbar();
|
|
867
|
+
});
|
|
868
|
+
});
|
|
869
|
+
const sa = el.querySelector('#job-select-all');
|
|
870
|
+
if (sa) {
|
|
871
|
+
sa.addEventListener('change', () => {
|
|
872
|
+
if (sa.checked) for (const j of jobs) jobSelection.add(j.id);
|
|
873
|
+
else for (const j of jobs) jobSelection.delete(j.id);
|
|
874
|
+
renderJobs(jobs);
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
updateBatchToolbar();
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
function updateBatchToolbar() {
|
|
881
|
+
const bar = document.getElementById('batch-toolbar');
|
|
882
|
+
const summary = document.getElementById('batch-summary');
|
|
883
|
+
const n = jobSelection.size;
|
|
884
|
+
bar.style.display = n > 0 ? '' : 'none';
|
|
885
|
+
summary.textContent = n > 0 ? `${n} selected` : '';
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
async function batchRun() {
|
|
889
|
+
const ids = Array.from(jobSelection);
|
|
890
|
+
if (!ids.length) { alert(T.jobsBatchEmpty); return; }
|
|
891
|
+
const r = await api('/api/jobs/batch-run', { method: 'POST', body: JSON.stringify({ ids }) });
|
|
892
|
+
const ok = r.results.filter(x => x.ok).length;
|
|
893
|
+
const fail = r.results.length - ok;
|
|
894
|
+
alert(T.jobsBatchResult.replace('{ok}', ok).replace('{fail}', fail));
|
|
895
|
+
jobSelection.clear();
|
|
896
|
+
loadJobs();
|
|
897
|
+
}
|
|
898
|
+
async function batchCancel() {
|
|
899
|
+
const ids = Array.from(jobSelection);
|
|
900
|
+
if (!ids.length) { alert(T.jobsBatchEmpty); return; }
|
|
901
|
+
const r = await api('/api/jobs/batch-cancel', { method: 'POST', body: JSON.stringify({ ids }) });
|
|
902
|
+
const ok = r.results.filter(x => x.ok).length;
|
|
903
|
+
const fail = r.results.length - ok;
|
|
904
|
+
alert(T.jobsBatchResult.replace('{ok}', ok).replace('{fail}', fail));
|
|
905
|
+
jobSelection.clear();
|
|
906
|
+
loadJobs();
|
|
907
|
+
}
|
|
908
|
+
document.getElementById('btn-batch-run').onclick = batchRun;
|
|
909
|
+
document.getElementById('btn-batch-cancel').onclick = batchCancel;
|
|
910
|
+
|
|
911
|
+
function esc(s) {
|
|
912
|
+
return String(s).replace(/[&<>"']/g, c => ({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}[c]));
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
async function loadJobs() {
|
|
916
|
+
const status = document.getElementById('filter-status').value;
|
|
917
|
+
const agent = document.getElementById('jobs-agent-filter').value;
|
|
918
|
+
const qs = new URLSearchParams({ limit: '100' });
|
|
919
|
+
if (status) qs.set('status', status);
|
|
920
|
+
if (agent) qs.set('agent', agent);
|
|
921
|
+
try {
|
|
922
|
+
const { jobs, stats } = await api('/api/jobs?' + qs.toString());
|
|
923
|
+
renderStats(stats);
|
|
924
|
+
renderJobs(jobs);
|
|
925
|
+
} catch (e) {
|
|
926
|
+
document.getElementById('jobs-list').innerHTML =
|
|
927
|
+
`<div class="empty">${T.error}: ${e.message}</div>`;
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
async function showJob(id) {
|
|
932
|
+
const { job } = await api('/api/jobs/' + id);
|
|
933
|
+
const m = document.getElementById('modal');
|
|
934
|
+
m.innerHTML = `
|
|
935
|
+
<h2>${T.details} #${job.id}</h2>
|
|
936
|
+
<p><strong>${T.agent}:</strong> <code>${job.agent}</code> · <strong>${T.status}:</strong> ${statusPill(job.status)}</p>
|
|
937
|
+
<p><strong>${T.created}:</strong> ${fmtTime(job.created_at)}${job.completed_at ? ` · <strong>${T.completed}:</strong> ${fmtTime(job.completed_at)}` : ''}</p>
|
|
938
|
+
<h3>${T.prompt}</h3>
|
|
939
|
+
<pre>${esc(job.prompt)}</pre>
|
|
940
|
+
${job.result ? `<h3>${T.result}</h3><pre>${esc(job.result)}</pre>` : ''}
|
|
941
|
+
${job.error ? `<h3>${T.error}</h3><pre>${esc(job.error)}</pre>` : ''}
|
|
942
|
+
<div class="modal-actions">
|
|
943
|
+
<button id="modal-close">${T.close}</button>
|
|
944
|
+
</div>
|
|
945
|
+
`;
|
|
946
|
+
document.getElementById('modal-bg').classList.add('show');
|
|
947
|
+
document.getElementById('modal-close').onclick = () =>
|
|
948
|
+
document.getElementById('modal-bg').classList.remove('show');
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
async function runJob(id) {
|
|
952
|
+
await api('/api/jobs/' + id + '/run', { method: 'POST' });
|
|
953
|
+
loadJobs();
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
async function cancelJob(id) {
|
|
957
|
+
await api('/api/jobs/' + id + '/cancel', { method: 'POST' });
|
|
958
|
+
loadJobs();
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// New job modal
|
|
962
|
+
document.getElementById('btn-new').onclick = async () => {
|
|
963
|
+
const agentsRes = await fetch('/api/agents/status', {
|
|
964
|
+
headers: { 'X-IM-Hub-Token': TOKEN },
|
|
965
|
+
}).then(r => r.json());
|
|
966
|
+
const agents = Object.keys(agentsRes);
|
|
967
|
+
|
|
968
|
+
const m = document.getElementById('modal');
|
|
969
|
+
m.innerHTML = `
|
|
970
|
+
<h2>${T.newJob}</h2>
|
|
971
|
+
<label>${T.agentCreate}<select id="ji-agent">
|
|
972
|
+
${agents.map(a => `<option value="${a}">${a}</option>`).join('')}
|
|
973
|
+
</select></label>
|
|
974
|
+
<label>${T.promptCreate}<textarea id="ji-prompt"></textarea></label>
|
|
975
|
+
<div class="modal-actions">
|
|
976
|
+
<button id="modal-close">${T.close}</button>
|
|
977
|
+
<button class="primary" id="modal-create" style="background:var(--primary);color:#fff;border-color:var(--primary);">${T.newJob}</button>
|
|
978
|
+
</div>
|
|
979
|
+
`;
|
|
980
|
+
document.getElementById('modal-bg').classList.add('show');
|
|
981
|
+
document.getElementById('modal-close').onclick = () =>
|
|
982
|
+
document.getElementById('modal-bg').classList.remove('show');
|
|
983
|
+
document.getElementById('modal-create').onclick = async () => {
|
|
984
|
+
const agent = document.getElementById('ji-agent').value;
|
|
985
|
+
const prompt = document.getElementById('ji-prompt').value.trim();
|
|
986
|
+
if (!prompt) return;
|
|
987
|
+
await api('/api/jobs', { method: 'POST', body: JSON.stringify({ agent, prompt }) });
|
|
988
|
+
document.getElementById('modal-bg').classList.remove('show');
|
|
989
|
+
loadJobs();
|
|
990
|
+
};
|
|
991
|
+
};
|
|
992
|
+
|
|
993
|
+
document.getElementById('btn-refresh').onclick = loadJobs;
|
|
994
|
+
document.getElementById('filter-status').onchange = loadJobs;
|
|
995
|
+
|
|
996
|
+
// Auto-refresh every 5s when checkbox is on
|
|
997
|
+
let timer = null;
|
|
998
|
+
function setupAutoRefresh() {
|
|
999
|
+
const on = document.getElementById('auto-refresh').checked;
|
|
1000
|
+
if (timer) { clearInterval(timer); timer = null; }
|
|
1001
|
+
if (on) timer = setInterval(loadJobs, 5000);
|
|
1002
|
+
}
|
|
1003
|
+
document.getElementById('auto-refresh').onchange = setupAutoRefresh;
|
|
1004
|
+
setupAutoRefresh();
|
|
1005
|
+
|
|
1006
|
+
// Schedules tab
|
|
1007
|
+
async function loadSchedules() {
|
|
1008
|
+
const el = document.getElementById('schedules-list');
|
|
1009
|
+
el.innerHTML = `<div class="empty">${T.loading}</div>`;
|
|
1010
|
+
const agent = document.getElementById('schedules-agent-filter').value;
|
|
1011
|
+
const qs = agent ? '?agent=' + encodeURIComponent(agent) : '';
|
|
1012
|
+
try {
|
|
1013
|
+
const { schedules } = await api('/api/schedules' + qs);
|
|
1014
|
+
if (!schedules.length) { el.innerHTML = `<div class="empty">${T.emptySchedules}</div>`; return; }
|
|
1015
|
+
el.innerHTML = `
|
|
1016
|
+
<table>
|
|
1017
|
+
<thead><tr>
|
|
1018
|
+
<th>#</th><th>${T.scheduleName}</th><th>${T.agent}</th><th>${T.scheduleCron}</th>
|
|
1019
|
+
<th>${T.status}</th><th>${T.scheduleNext}</th><th>${T.scheduleLast}</th>
|
|
1020
|
+
</tr></thead>
|
|
1021
|
+
<tbody>
|
|
1022
|
+
${schedules.map(s => `
|
|
1023
|
+
<tr>
|
|
1024
|
+
<td>#${s.id}</td>
|
|
1025
|
+
<td>${esc(s.name)}</td>
|
|
1026
|
+
<td><code>${s.agent}</code></td>
|
|
1027
|
+
<td><code>${esc(s.cron)}</code></td>
|
|
1028
|
+
<td>${s.enabled ? '✅' : '⏸️'}</td>
|
|
1029
|
+
<td>${fmtTime(s.next_run)}</td>
|
|
1030
|
+
<td>${fmtTime(s.last_run)}</td>
|
|
1031
|
+
</tr>
|
|
1032
|
+
`).join('')}
|
|
1033
|
+
</tbody>
|
|
1034
|
+
</table>
|
|
1035
|
+
`;
|
|
1036
|
+
} catch (e) {
|
|
1037
|
+
el.innerHTML = `<div class="empty">${T.error}: ${e.message}</div>`;
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
// ============================================
|
|
1042
|
+
// Background jobs (Claude / opencode / Codex bgjob)
|
|
1043
|
+
// ============================================
|
|
1044
|
+
|
|
1045
|
+
let bgRoots = null; // [{id, label, path}]
|
|
1046
|
+
|
|
1047
|
+
async function ensureBgRootsLoaded() {
|
|
1048
|
+
if (bgRoots) return bgRoots;
|
|
1049
|
+
const res = await api('/api/bgjobs');
|
|
1050
|
+
bgRoots = res.roots || [];
|
|
1051
|
+
const sel = document.getElementById('bg-root-filter');
|
|
1052
|
+
sel.innerHTML = bgRoots.map(r =>
|
|
1053
|
+
`<option value="${esc(r.id)}">${esc(r.label)} (${esc(r.path)})</option>`
|
|
1054
|
+
).join('');
|
|
1055
|
+
sel.onchange = loadBgjobs;
|
|
1056
|
+
return bgRoots;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
function bgStatusPill(status) {
|
|
1060
|
+
// Reuse pill colors heuristically — running/completed/failed are common,
|
|
1061
|
+
// bgjob also emits 'killed', 'unknown', etc. Anything we don't know
|
|
1062
|
+
// becomes the muted 'cancelled' style.
|
|
1063
|
+
const known = ['pending', 'running', 'completed', 'failed', 'cancelled'];
|
|
1064
|
+
const cls = known.includes(status) ? status : 'cancelled';
|
|
1065
|
+
return `<span class="pill ${cls}">${esc(status)}</span>`;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
function shortId(id) { return String(id).slice(0, 24); }
|
|
1069
|
+
|
|
1070
|
+
async function loadBgjobs() {
|
|
1071
|
+
await ensureBgRootsLoaded();
|
|
1072
|
+
const sel = document.getElementById('bg-root-filter');
|
|
1073
|
+
const rootId = sel.value || (bgRoots[0] && bgRoots[0].id);
|
|
1074
|
+
if (!rootId) {
|
|
1075
|
+
document.getElementById('bg-list').innerHTML = `<div class="empty">${T.bgEmpty}</div>`;
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
1078
|
+
try {
|
|
1079
|
+
const { jobs } = await api('/api/bgjobs?root=' + encodeURIComponent(rootId));
|
|
1080
|
+
const el = document.getElementById('bg-list');
|
|
1081
|
+
if (!jobs.length) { el.innerHTML = `<div class="empty">${T.bgEmpty}</div>`; return; }
|
|
1082
|
+
el.innerHTML = `
|
|
1083
|
+
<table>
|
|
1084
|
+
<thead>
|
|
1085
|
+
<tr>
|
|
1086
|
+
<th>${T.bgName}</th>
|
|
1087
|
+
<th>ID</th>
|
|
1088
|
+
<th>${T.status}</th>
|
|
1089
|
+
<th>${T.bgPid}</th>
|
|
1090
|
+
<th>${T.bgStarted}</th>
|
|
1091
|
+
<th>${T.bgEnded}</th>
|
|
1092
|
+
<th>${T.bgExit}</th>
|
|
1093
|
+
<th>${T.actions}</th>
|
|
1094
|
+
</tr>
|
|
1095
|
+
</thead>
|
|
1096
|
+
<tbody>
|
|
1097
|
+
${jobs.map(j => `
|
|
1098
|
+
<tr data-id="${esc(j.id)}">
|
|
1099
|
+
<td>${esc(j.name)}</td>
|
|
1100
|
+
<td><code title="${esc(j.id)}">${esc(shortId(j.id))}</code></td>
|
|
1101
|
+
<td>${bgStatusPill(j.status)}</td>
|
|
1102
|
+
<td>${j.pid ?? '-'}</td>
|
|
1103
|
+
<td>${fmtTime(j.started_at)}</td>
|
|
1104
|
+
<td>${fmtTime(j.ended_at)}</td>
|
|
1105
|
+
<td>${j.exit_code === null || j.exit_code === undefined ? '-' : j.exit_code}</td>
|
|
1106
|
+
<td class="row-actions">
|
|
1107
|
+
<button data-act="bg-view">${T.view}</button>
|
|
1108
|
+
</td>
|
|
1109
|
+
</tr>
|
|
1110
|
+
`).join('')}
|
|
1111
|
+
</tbody>
|
|
1112
|
+
</table>
|
|
1113
|
+
`;
|
|
1114
|
+
el.querySelectorAll('tr[data-id]').forEach(tr => {
|
|
1115
|
+
const id = tr.dataset.id;
|
|
1116
|
+
tr.querySelector('[data-act="bg-view"]').addEventListener('click', () => showBgjob(rootId, id));
|
|
1117
|
+
});
|
|
1118
|
+
} catch (e) {
|
|
1119
|
+
document.getElementById('bg-list').innerHTML =
|
|
1120
|
+
`<div class="empty">${T.error}: ${esc(e.message)}</div>`;
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
async function showBgjob(rootId, id) {
|
|
1125
|
+
try {
|
|
1126
|
+
const { job } = await api('/api/bgjobs/' + encodeURIComponent(id) + '?root=' + encodeURIComponent(rootId) + '&tail=200');
|
|
1127
|
+
const m = document.getElementById('modal');
|
|
1128
|
+
m.innerHTML = `
|
|
1129
|
+
<h2>${T.details}</h2>
|
|
1130
|
+
<p><strong>${T.bgName}:</strong> <code>${esc(job.name)}</code> · <strong>${T.status}:</strong> ${bgStatusPill(job.status)}</p>
|
|
1131
|
+
<p><strong>ID:</strong> <code>${esc(job.id)}</code></p>
|
|
1132
|
+
<p><strong>${T.bgRoot}:</strong> <code>${esc(job.rootId)}</code> · <strong>${T.bgPid}:</strong> ${job.pid ?? '-'} · <strong>${T.bgRestart}:</strong> ${job.restart_generation}</p>
|
|
1133
|
+
<p><strong>${T.bgStarted}:</strong> ${fmtTime(job.started_at)}${job.ended_at ? ` · <strong>${T.bgEnded}:</strong> ${fmtTime(job.ended_at)}` : ''}${job.exit_code !== null && job.exit_code !== undefined ? ` · <strong>${T.bgExit}:</strong> ${job.exit_code}` : ''}</p>
|
|
1134
|
+
${job.cmd?.length ? `<h3>${T.bgCommand}</h3><pre>${esc(job.cmd.join(' '))}</pre>` : ''}
|
|
1135
|
+
${job.workdir ? `<p><strong>${T.bgWorkdir}:</strong> <code>${esc(job.workdir)}</code></p>` : ''}
|
|
1136
|
+
${job.out_dir ? `<p><strong>${T.bgOutDir}:</strong> <code>${esc(job.out_dir)}</code></p>` : ''}
|
|
1137
|
+
${job.log_tail !== null && job.log_tail !== undefined
|
|
1138
|
+
? `<h3>${T.bgLogTail}</h3><pre>${esc(job.log_tail) || '(empty)'}</pre>`
|
|
1139
|
+
: ''}
|
|
1140
|
+
<div class="modal-actions">
|
|
1141
|
+
<button id="modal-close">${T.close}</button>
|
|
1142
|
+
</div>
|
|
1143
|
+
`;
|
|
1144
|
+
document.getElementById('modal-bg').classList.add('show');
|
|
1145
|
+
document.getElementById('modal-close').onclick = () =>
|
|
1146
|
+
document.getElementById('modal-bg').classList.remove('show');
|
|
1147
|
+
} catch (e) {
|
|
1148
|
+
alert(`${T.error}: ${e.message}`);
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
document.getElementById('btn-bg-refresh').onclick = loadBgjobs;
|
|
1153
|
+
|
|
1154
|
+
let bgTimer = null;
|
|
1155
|
+
function setupBgAutoRefresh() {
|
|
1156
|
+
const onTab = !document.getElementById('background-pane').hidden;
|
|
1157
|
+
const enabled = document.getElementById('bg-auto-refresh').checked && onTab;
|
|
1158
|
+
if (bgTimer) { clearInterval(bgTimer); bgTimer = null; }
|
|
1159
|
+
if (enabled) bgTimer = setInterval(loadBgjobs, 5000);
|
|
1160
|
+
}
|
|
1161
|
+
document.getElementById('bg-auto-refresh').onchange = setupBgAutoRefresh;
|
|
1162
|
+
|
|
1163
|
+
// ============================================
|
|
1164
|
+
// Subtasks (flattened from session.subtasks)
|
|
1165
|
+
// ============================================
|
|
1166
|
+
|
|
1167
|
+
async function loadSubtasks() {
|
|
1168
|
+
const el = document.getElementById('sub-list');
|
|
1169
|
+
el.innerHTML = `<div class="empty">${T.loading}</div>`;
|
|
1170
|
+
const agent = document.getElementById('subtasks-agent-filter').value;
|
|
1171
|
+
const qs = agent ? '?agent=' + encodeURIComponent(agent) : '';
|
|
1172
|
+
try {
|
|
1173
|
+
const { subtasks } = await api('/api/subtasks' + qs);
|
|
1174
|
+
if (!subtasks.length) { el.innerHTML = `<div class="empty">${T.subEmpty}</div>`; return; }
|
|
1175
|
+
el.innerHTML = `
|
|
1176
|
+
<table>
|
|
1177
|
+
<thead>
|
|
1178
|
+
<tr>
|
|
1179
|
+
<th>#</th>
|
|
1180
|
+
<th>${T.subAgent}</th>
|
|
1181
|
+
<th>${T.subPrompt}</th>
|
|
1182
|
+
<th>${T.status}</th>
|
|
1183
|
+
<th>${T.subParent}</th>
|
|
1184
|
+
<th>${T.subCreated}</th>
|
|
1185
|
+
</tr>
|
|
1186
|
+
</thead>
|
|
1187
|
+
<tbody>
|
|
1188
|
+
${subtasks.map(s => `
|
|
1189
|
+
<tr>
|
|
1190
|
+
<td>#${s.id}</td>
|
|
1191
|
+
<td><code>${esc(s.agent || '')}</code></td>
|
|
1192
|
+
<td class="truncate">${esc(s.prompt || '')}</td>
|
|
1193
|
+
<td>${statusPill(s.status || 'pending')}</td>
|
|
1194
|
+
<td><code title="${esc(s.platform + ':' + s.channelId + ':' + s.threadId)}">${esc(s.platform)}/${esc(s.threadId).slice(0, 12)}</code></td>
|
|
1195
|
+
<td>${fmtTime(s.createdAt)}</td>
|
|
1196
|
+
</tr>
|
|
1197
|
+
`).join('')}
|
|
1198
|
+
</tbody>
|
|
1199
|
+
</table>
|
|
1200
|
+
`;
|
|
1201
|
+
} catch (e) {
|
|
1202
|
+
el.innerHTML = `<div class="empty">${T.error}: ${esc(e.message)}</div>`;
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
document.getElementById('btn-sub-refresh').onclick = loadSubtasks;
|
|
1207
|
+
|
|
1208
|
+
// ============================================
|
|
1209
|
+
// Audit tab — invocations history with agent / days / user filter
|
|
1210
|
+
// ============================================
|
|
1211
|
+
|
|
1212
|
+
async function loadAudit() {
|
|
1213
|
+
const el = document.getElementById('audit-list');
|
|
1214
|
+
el.innerHTML = `<div class="empty">${T.loading}</div>`;
|
|
1215
|
+
const agent = document.getElementById('audit-agent-filter').value;
|
|
1216
|
+
const days = document.getElementById('audit-days-filter').value || '7';
|
|
1217
|
+
const user = document.getElementById('audit-user-filter').value.trim();
|
|
1218
|
+
const qs = new URLSearchParams({ days, limit: '200' });
|
|
1219
|
+
if (agent) qs.set('agent', agent);
|
|
1220
|
+
if (user) qs.set('user', user);
|
|
1221
|
+
try {
|
|
1222
|
+
const { invocations, stats } = await api('/api/audit?' + qs.toString());
|
|
1223
|
+
// Stats strip
|
|
1224
|
+
const statsEl = document.getElementById('audit-stats');
|
|
1225
|
+
if (stats && typeof stats.total === 'number') {
|
|
1226
|
+
const top = Object.entries(stats.byAgent || {})
|
|
1227
|
+
.sort((a, b) => b[1] - a[1]).slice(0, 4)
|
|
1228
|
+
.map(([a, n]) => `${esc(a)}=${n}`).join(' · ');
|
|
1229
|
+
statsEl.innerHTML = `Total: <strong>${stats.total}</strong> · ` +
|
|
1230
|
+
`Cost: $${(stats.totalCost || 0).toFixed(4)} · By agent: ${top || '—'}`;
|
|
1231
|
+
} else {
|
|
1232
|
+
statsEl.innerHTML = '';
|
|
1233
|
+
}
|
|
1234
|
+
if (!invocations.length) {
|
|
1235
|
+
el.innerHTML = `<div class="empty">${T.auditEmpty}</div>`;
|
|
1236
|
+
return;
|
|
1237
|
+
}
|
|
1238
|
+
el.innerHTML = `
|
|
1239
|
+
<table>
|
|
1240
|
+
<thead><tr>
|
|
1241
|
+
<th>${T.auditTime}</th>
|
|
1242
|
+
<th>${T.agent}</th>
|
|
1243
|
+
<th>${T.auditPlatform}</th>
|
|
1244
|
+
<th>${T.auditUser}</th>
|
|
1245
|
+
<th>${T.auditIntent}</th>
|
|
1246
|
+
<th>${T.auditDuration}</th>
|
|
1247
|
+
<th>${T.auditCost}</th>
|
|
1248
|
+
<th>${T.status}</th>
|
|
1249
|
+
</tr></thead>
|
|
1250
|
+
<tbody>
|
|
1251
|
+
${invocations.map(r => `
|
|
1252
|
+
<tr title="trace: ${esc(r.trace_id || '')}${r.error ? '\n' + esc(r.error) : ''}">
|
|
1253
|
+
<td>${fmtTime(r.ts)}</td>
|
|
1254
|
+
<td><code>${esc(r.agent)}</code></td>
|
|
1255
|
+
<td>${esc(r.platform)}</td>
|
|
1256
|
+
<td><code>${esc(r.user_id || '-')}</code></td>
|
|
1257
|
+
<td>${esc(r.intent || '-')}</td>
|
|
1258
|
+
<td>${(r.duration_ms / 1000).toFixed(2)}s</td>
|
|
1259
|
+
<td>${r.cost ? '$' + Number(r.cost).toFixed(4) : '-'}</td>
|
|
1260
|
+
<td>${r.success ? '✅' : '❌'}</td>
|
|
1261
|
+
</tr>
|
|
1262
|
+
`).join('')}
|
|
1263
|
+
</tbody>
|
|
1264
|
+
</table>
|
|
1265
|
+
`;
|
|
1266
|
+
} catch (e) {
|
|
1267
|
+
el.innerHTML = `<div class="empty">${T.error}: ${esc(e.message)}</div>`;
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
// Agent-filter change handlers per tab. Each dropdown re-runs its tab's
|
|
1272
|
+
// loader; tabs that haven't been opened yet still hold the selection.
|
|
1273
|
+
document.getElementById('jobs-agent-filter').addEventListener('change', loadJobs);
|
|
1274
|
+
document.getElementById('subtasks-agent-filter').addEventListener('change', loadSubtasks);
|
|
1275
|
+
document.getElementById('schedules-agent-filter').addEventListener('change', loadSchedules);
|
|
1276
|
+
document.getElementById('audit-agent-filter').addEventListener('change', loadAudit);
|
|
1277
|
+
document.getElementById('audit-days-filter').addEventListener('change', loadAudit);
|
|
1278
|
+
// User filter is debounced — refresh on blur / Enter, not on every keystroke.
|
|
1279
|
+
document.getElementById('audit-user-filter').addEventListener('change', loadAudit);
|
|
1280
|
+
document.getElementById('btn-audit-refresh').onclick = loadAudit;
|
|
1281
|
+
|
|
1282
|
+
// Schedules tab also gets an explicit refresh button now that it has a
|
|
1283
|
+
// toolbar (was previously bare-list); keeps parity with other tabs.
|
|
1284
|
+
const btnSchedRefresh = document.getElementById('btn-sched-refresh');
|
|
1285
|
+
if (btnSchedRefresh) btnSchedRefresh.onclick = loadSchedules;
|
|
1286
|
+
|
|
1287
|
+
// ============================================================
|
|
1288
|
+
// Health tab (PR-B)
|
|
1289
|
+
// ============================================================
|
|
1290
|
+
// Per-agent operational snapshot. Polled every 5 s when the tab is
|
|
1291
|
+
// visible (auto-refresh checkbox controls). The sparkline buffer is
|
|
1292
|
+
// kept client-side — each poll appends current p95 to a 60-sample
|
|
1293
|
+
// ring per agent, rendered as a tiny inline SVG. Stays in memory
|
|
1294
|
+
// only; no localStorage persistence.
|
|
1295
|
+
|
|
1296
|
+
const HEALTH_POLL_MS = 5_000;
|
|
1297
|
+
const SPARKLINE_LEN = 60;
|
|
1298
|
+
const sparkBuffers = new Map(); // agent → number[] (p95 history)
|
|
1299
|
+
let healthTimer = null;
|
|
1300
|
+
|
|
1301
|
+
function pushSpark(agent, value) {
|
|
1302
|
+
let buf = sparkBuffers.get(agent);
|
|
1303
|
+
if (!buf) { buf = []; sparkBuffers.set(agent, buf); }
|
|
1304
|
+
buf.push(Number(value) || 0);
|
|
1305
|
+
while (buf.length > SPARKLINE_LEN) buf.shift();
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
function renderSparkline(values, w = 120, h = 28) {
|
|
1309
|
+
if (!values.length) return '';
|
|
1310
|
+
const max = Math.max(1, ...values);
|
|
1311
|
+
const step = w / Math.max(1, SPARKLINE_LEN - 1);
|
|
1312
|
+
const points = values.map((v, i) =>
|
|
1313
|
+
`${(i * step).toFixed(1)},${(h - (v / max) * h * 0.92 - 1).toFixed(1)}`
|
|
1314
|
+
).join(' ');
|
|
1315
|
+
const last = values[values.length - 1];
|
|
1316
|
+
const dotX = ((values.length - 1) * step).toFixed(1);
|
|
1317
|
+
const dotY = (h - (last / max) * h * 0.92 - 1).toFixed(1);
|
|
1318
|
+
return `<svg width="${w}" height="${h}" viewBox="0 0 ${w} ${h}" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
|
1319
|
+
<polyline fill="none" stroke="var(--primary)" stroke-width="1.5" points="${points}" />
|
|
1320
|
+
<circle cx="${dotX}" cy="${dotY}" r="2" fill="var(--primary)" />
|
|
1321
|
+
</svg>`;
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
function breakerPill(phase) {
|
|
1325
|
+
const map = {
|
|
1326
|
+
closed: { label: T.breakerClosed, bg: 'var(--success)' },
|
|
1327
|
+
open: { label: T.breakerOpen, bg: 'var(--danger)' },
|
|
1328
|
+
'half-open': { label: T.breakerHalfOpen, bg: 'var(--warning)' },
|
|
1329
|
+
};
|
|
1330
|
+
const o = map[phase] || { label: phase, bg: 'var(--muted)' };
|
|
1331
|
+
return `<span style="display:inline-block;padding:2px 10px;border-radius:10px;font-size:11px;
|
|
1332
|
+
background:${o.bg};color:#fff">${o.label}</span>`;
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
function renderHealth(payload) {
|
|
1336
|
+
const list = document.getElementById('health-list');
|
|
1337
|
+
const uptime = document.getElementById('health-uptime');
|
|
1338
|
+
const days = Math.floor(payload.uptimeSec / 86400);
|
|
1339
|
+
const hours = Math.floor((payload.uptimeSec % 86400) / 3600);
|
|
1340
|
+
const mins = Math.floor((payload.uptimeSec % 3600) / 60);
|
|
1341
|
+
uptime.textContent = `uptime ${days > 0 ? days + 'd ' : ''}${hours}h ${mins}m`;
|
|
1342
|
+
if (!payload.agents.length) {
|
|
1343
|
+
list.innerHTML = `<div class="empty">${T.healthEmpty}</div>`;
|
|
1344
|
+
return;
|
|
1345
|
+
}
|
|
1346
|
+
list.innerHTML = payload.agents.map((a) => {
|
|
1347
|
+
const inv = a.invocations;
|
|
1348
|
+
const p95 = inv ? inv.p95Ms : 0;
|
|
1349
|
+
pushSpark(a.agent, p95);
|
|
1350
|
+
const buf = sparkBuffers.get(a.agent) || [];
|
|
1351
|
+
const cooldownLine = a.breaker.phase !== 'closed'
|
|
1352
|
+
? `<div class="ac-row"><b>${T.healthCooldown}:</b> ${(a.breaker.cooldownRemainingMs / 1000).toFixed(0)}s</div>`
|
|
1353
|
+
: '';
|
|
1354
|
+
const successPct = inv && inv.total > 0 ? (inv.successRate * 100).toFixed(1) + '%' : '—';
|
|
1355
|
+
const cost = inv && inv.costSum ? '$' + inv.costSum.toFixed(4) : '—';
|
|
1356
|
+
return `
|
|
1357
|
+
<div class="health-card">
|
|
1358
|
+
<div class="health-head">
|
|
1359
|
+
<code class="health-agent">${esc(a.agent)}</code>
|
|
1360
|
+
${breakerPill(a.breaker.phase)}
|
|
1361
|
+
</div>
|
|
1362
|
+
<div class="health-grid">
|
|
1363
|
+
<div>
|
|
1364
|
+
<div class="health-label">${T.healthBreaker}</div>
|
|
1365
|
+
<div class="health-val">${esc(a.breaker.phase)}${a.breaker.failures > 0 ? ` · ${a.breaker.failures} fail` : ''}</div>
|
|
1366
|
+
</div>
|
|
1367
|
+
<div>
|
|
1368
|
+
<div class="health-label">${T.healthRate}</div>
|
|
1369
|
+
<div class="health-val">${a.rate.remaining}/${a.rate.rate}/${a.rate.intervalSec}s</div>
|
|
1370
|
+
</div>
|
|
1371
|
+
<div>
|
|
1372
|
+
<div class="health-label">${T.healthLatency} p50/p95/p99</div>
|
|
1373
|
+
<div class="health-val">${inv ? `${inv.p50Ms}/${inv.p95Ms}/${inv.p99Ms}ms` : '—'}</div>
|
|
1374
|
+
</div>
|
|
1375
|
+
<div>
|
|
1376
|
+
<div class="health-label">${T.healthInvocations}</div>
|
|
1377
|
+
<div class="health-val">${inv ? inv.total : 0} · ${T.healthSuccessRate} ${successPct}</div>
|
|
1378
|
+
</div>
|
|
1379
|
+
<div>
|
|
1380
|
+
<div class="health-label">${T.healthCost}</div>
|
|
1381
|
+
<div class="health-val">${cost}</div>
|
|
1382
|
+
</div>
|
|
1383
|
+
<div title="${T.healthSparklineLabel}">
|
|
1384
|
+
<div class="health-label">p95 (${buf.length}/${SPARKLINE_LEN})</div>
|
|
1385
|
+
<div class="health-spark">${renderSparkline(buf)}</div>
|
|
1386
|
+
</div>
|
|
1387
|
+
</div>
|
|
1388
|
+
${cooldownLine}
|
|
1389
|
+
</div>
|
|
1390
|
+
`;
|
|
1391
|
+
}).join('');
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
async function loadHealth() {
|
|
1395
|
+
try {
|
|
1396
|
+
const data = await api('/api/agent-health');
|
|
1397
|
+
renderHealth(data);
|
|
1398
|
+
} catch (e) {
|
|
1399
|
+
document.getElementById('health-list').innerHTML =
|
|
1400
|
+
`<div class="empty">${T.error}: ${esc(e.message)}</div>`;
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
function setupHealthAutoRefresh() {
|
|
1405
|
+
const isHealth = !document.getElementById('health-pane').hidden;
|
|
1406
|
+
const on = document.getElementById('health-auto-refresh').checked && isHealth;
|
|
1407
|
+
if (healthTimer) { clearInterval(healthTimer); healthTimer = null; }
|
|
1408
|
+
if (on) healthTimer = setInterval(loadHealth, HEALTH_POLL_MS);
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
document.getElementById('btn-health-refresh').onclick = loadHealth;
|
|
1412
|
+
document.getElementById('health-auto-refresh').onchange = setupHealthAutoRefresh;
|
|
1413
|
+
|
|
1414
|
+
// ============================================================
|
|
1415
|
+
// Approvals tab (PR-B)
|
|
1416
|
+
// ============================================================
|
|
1417
|
+
// Global view of every currently-pending HITL approval. Operator can
|
|
1418
|
+
// resolve from here without needing access to the original IM thread
|
|
1419
|
+
// — useful when an approval lands while the requester is offline. The
|
|
1420
|
+
// server validates the reqId still exists; if not (already resolved /
|
|
1421
|
+
// timed out) we get a 404 + refresh.
|
|
1422
|
+
|
|
1423
|
+
const APPROVALS_POLL_MS = 3_000;
|
|
1424
|
+
let approvalsTimer = null;
|
|
1425
|
+
|
|
1426
|
+
function fmtAge(ms) {
|
|
1427
|
+
const s = Math.max(0, Math.floor(ms / 1000));
|
|
1428
|
+
if (s < 60) return s + 's';
|
|
1429
|
+
const m = Math.floor(s / 60);
|
|
1430
|
+
if (m < 60) return m + 'm ' + (s % 60) + 's';
|
|
1431
|
+
const h = Math.floor(m / 60);
|
|
1432
|
+
return h + 'h ' + (m % 60) + 'm';
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
function renderApprovals(payload) {
|
|
1436
|
+
const list = document.getElementById('approvals-list');
|
|
1437
|
+
const summary = document.getElementById('approvals-summary');
|
|
1438
|
+
const pending = payload.pending || [];
|
|
1439
|
+
summary.textContent = T.approvalsCount.replace('{n}', pending.length);
|
|
1440
|
+
if (!pending.length) {
|
|
1441
|
+
list.innerHTML = `<div class="empty">${T.approvalsEmpty}</div>`;
|
|
1442
|
+
return;
|
|
1443
|
+
}
|
|
1444
|
+
list.innerHTML = pending.map((p) => {
|
|
1445
|
+
let inputPreview = '';
|
|
1446
|
+
try { inputPreview = JSON.stringify(p.input, null, 2); } catch { inputPreview = '(unserializable)'; }
|
|
1447
|
+
if (inputPreview.length > 600) inputPreview = inputPreview.slice(0, 600) + '\n...';
|
|
1448
|
+
const autoTag = p.autoAllow
|
|
1449
|
+
? `<span style="color:var(--warning);font-size:11px;margin-left:6px">⏱ ${T.approvalsAutoMode}</span>`
|
|
1450
|
+
: '';
|
|
1451
|
+
return `
|
|
1452
|
+
<div class="approval-item" data-req-id="${esc(p.reqId)}">
|
|
1453
|
+
<div class="approval-head">
|
|
1454
|
+
<code>${esc(p.toolName)}</code>${autoTag}
|
|
1455
|
+
<span class="approval-meta">${esc(p.platform)} · ${T.approvalsThread}: ${esc(String(p.threadId).slice(0, 24))} · ${T.approvalsAge}: ${fmtAge(p.ageMs)}</span>
|
|
1456
|
+
</div>
|
|
1457
|
+
<pre class="approval-input">${esc(inputPreview)}</pre>
|
|
1458
|
+
<div class="approval-actions">
|
|
1459
|
+
<button data-act="allow" class="btn-allow">${T.approvalsAllow}</button>
|
|
1460
|
+
<button data-act="deny" class="btn-deny">${T.approvalsDeny}</button>
|
|
1461
|
+
<button data-act="allowAll">${T.approvalsAllowAll}</button>
|
|
1462
|
+
</div>
|
|
1463
|
+
</div>
|
|
1464
|
+
`;
|
|
1465
|
+
}).join('');
|
|
1466
|
+
list.querySelectorAll('.approval-item').forEach((item) => {
|
|
1467
|
+
const reqId = item.dataset.reqId;
|
|
1468
|
+
item.querySelector('[data-act="allow"]').onclick = () => resolveApproval(reqId, 'allow', false);
|
|
1469
|
+
item.querySelector('[data-act="deny"]').onclick = () => resolveApproval(reqId, 'deny', false);
|
|
1470
|
+
item.querySelector('[data-act="allowAll"]').onclick = () => resolveApproval(reqId, 'allow', true);
|
|
1471
|
+
});
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
async function resolveApproval(reqId, behavior, autoAllowFurther) {
|
|
1475
|
+
const item = document.querySelector(`.approval-item[data-req-id="${reqId}"]`);
|
|
1476
|
+
if (item) item.querySelectorAll('button').forEach((b) => (b.disabled = true));
|
|
1477
|
+
try {
|
|
1478
|
+
await api(`/api/approvals/${encodeURIComponent(reqId)}/resolve`, {
|
|
1479
|
+
method: 'POST',
|
|
1480
|
+
body: JSON.stringify({ behavior, autoAllowFurther }),
|
|
1481
|
+
});
|
|
1482
|
+
loadApprovals();
|
|
1483
|
+
} catch (e) {
|
|
1484
|
+
if (item) {
|
|
1485
|
+
const err = document.createElement('div');
|
|
1486
|
+
err.style.cssText = 'color:var(--danger);font-size:12px;margin-top:6px';
|
|
1487
|
+
err.textContent = `${T.approvalsResolveErr}: ${e.message}`;
|
|
1488
|
+
item.appendChild(err);
|
|
1489
|
+
item.querySelectorAll('button').forEach((b) => (b.disabled = false));
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
async function loadApprovals() {
|
|
1495
|
+
try {
|
|
1496
|
+
const data = await api('/api/approvals');
|
|
1497
|
+
renderApprovals(data);
|
|
1498
|
+
} catch (e) {
|
|
1499
|
+
document.getElementById('approvals-list').innerHTML =
|
|
1500
|
+
`<div class="empty">${T.error}: ${esc(e.message)}</div>`;
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
function setupApprovalsAutoRefresh() {
|
|
1505
|
+
const isApprovals = !document.getElementById('approvals-pane').hidden;
|
|
1506
|
+
const on = document.getElementById('approvals-auto-refresh').checked && isApprovals;
|
|
1507
|
+
if (approvalsTimer) { clearInterval(approvalsTimer); approvalsTimer = null; }
|
|
1508
|
+
if (on) approvalsTimer = setInterval(loadApprovals, APPROVALS_POLL_MS);
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
document.getElementById('btn-approvals-refresh').onclick = loadApprovals;
|
|
1512
|
+
document.getElementById('approvals-auto-refresh').onchange = setupApprovalsAutoRefresh;
|
|
1513
|
+
|
|
1514
|
+
// ============================================================
|
|
1515
|
+
// Files tab (PR-D) — read-only browser of ~/.im-hub-workspaces/<agent>/
|
|
1516
|
+
// ============================================================
|
|
1517
|
+
// Pure pull model: list directory or peek a file via /api/workspace-files.
|
|
1518
|
+
// No write/edit affordances by design; ops use ssh.
|
|
1519
|
+
|
|
1520
|
+
let filesAgent = ''; // currently-selected agent
|
|
1521
|
+
let filesPath = ''; // current relative path under that agent's root
|
|
1522
|
+
let filesAgentsLoaded = false;
|
|
1523
|
+
|
|
1524
|
+
async function ensureFilesAgentLoaded() {
|
|
1525
|
+
if (filesAgentsLoaded) return;
|
|
1526
|
+
filesAgentsLoaded = true;
|
|
1527
|
+
try {
|
|
1528
|
+
const agentsRes = await fetch('/api/agents/status', {
|
|
1529
|
+
headers: { 'X-IM-Hub-Token': TOKEN },
|
|
1530
|
+
}).then(r => r.json());
|
|
1531
|
+
const names = Object.keys(agentsRes);
|
|
1532
|
+
const sel = document.getElementById('files-agent');
|
|
1533
|
+
sel.innerHTML = names.length
|
|
1534
|
+
? names.map(n => `<option value="${esc(n)}">${esc(n)}</option>`).join('')
|
|
1535
|
+
: '';
|
|
1536
|
+
if (!names.length) {
|
|
1537
|
+
document.getElementById('files-tree').innerHTML = `<div class="empty">${T.filesNoAgent}</div>`;
|
|
1538
|
+
return;
|
|
1539
|
+
}
|
|
1540
|
+
filesAgent = sel.value = names[0];
|
|
1541
|
+
} catch {
|
|
1542
|
+
filesAgentsLoaded = false; // allow retry next time
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
function fmtBytes(n) {
|
|
1547
|
+
if (n == null) return '-';
|
|
1548
|
+
if (n < 1024) return n + ' B';
|
|
1549
|
+
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
|
|
1550
|
+
return (n / (1024 * 1024)).toFixed(1) + ' MB';
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
async function loadFiles(path) {
|
|
1554
|
+
filesPath = path || '';
|
|
1555
|
+
const tree = document.getElementById('files-tree');
|
|
1556
|
+
const content = document.getElementById('files-content');
|
|
1557
|
+
const cur = document.getElementById('files-current');
|
|
1558
|
+
cur.textContent = filesPath || T.filesRoot;
|
|
1559
|
+
if (!filesAgent) { tree.innerHTML = `<div class="empty">${T.filesNoAgent}</div>`; return; }
|
|
1560
|
+
tree.innerHTML = `<div class="empty">${T.loading}</div>`;
|
|
1561
|
+
try {
|
|
1562
|
+
const qs = new URLSearchParams({ agent: filesAgent, path: filesPath });
|
|
1563
|
+
const data = await api('/api/workspace-files?' + qs.toString());
|
|
1564
|
+
if (data.type === 'dir') {
|
|
1565
|
+
if (!data.entries.length) {
|
|
1566
|
+
tree.innerHTML = `<div class="empty">${T.filesEmpty}</div>`;
|
|
1567
|
+
} else {
|
|
1568
|
+
tree.innerHTML = `
|
|
1569
|
+
<table>
|
|
1570
|
+
<thead><tr><th>${T.filesPath}</th><th>${T.filesSize}</th><th>${T.filesMtime}</th></tr></thead>
|
|
1571
|
+
<tbody>
|
|
1572
|
+
${data.entries.map(e => `
|
|
1573
|
+
<tr data-name="${esc(e.name)}" data-isdir="${e.isDir ? '1' : '0'}" style="cursor:pointer">
|
|
1574
|
+
<td>${e.isDir ? '📁 ' : '📄 '}${esc(e.name)}${e.broken ? ' ⚠' : ''}</td>
|
|
1575
|
+
<td>${e.isDir ? '-' : fmtBytes(e.size)}</td>
|
|
1576
|
+
<td>${e.mtime ? fmtTime(e.mtime) : '-'}</td>
|
|
1577
|
+
</tr>
|
|
1578
|
+
`).join('')}
|
|
1579
|
+
</tbody>
|
|
1580
|
+
</table>
|
|
1581
|
+
`;
|
|
1582
|
+
tree.querySelectorAll('tr[data-name]').forEach(tr => {
|
|
1583
|
+
tr.addEventListener('click', () => {
|
|
1584
|
+
const name = tr.dataset.name;
|
|
1585
|
+
const isDir = tr.dataset.isdir === '1';
|
|
1586
|
+
const next = filesPath ? filesPath + '/' + name : name;
|
|
1587
|
+
if (isDir) loadFiles(next);
|
|
1588
|
+
else loadFiles(next); // file path → server returns type:'file'
|
|
1589
|
+
});
|
|
1590
|
+
});
|
|
1591
|
+
}
|
|
1592
|
+
content.innerHTML = '';
|
|
1593
|
+
} else if (data.type === 'file') {
|
|
1594
|
+
// Re-render directory listing for the parent so the user keeps
|
|
1595
|
+
// context after clicking a file. Cheap: same path -1 segment.
|
|
1596
|
+
const parent = filesPath.includes('/') ? filesPath.slice(0, filesPath.lastIndexOf('/')) : '';
|
|
1597
|
+
// Don't recurse — load parent listing without overwriting filesPath.
|
|
1598
|
+
await loadDirOnly(parent);
|
|
1599
|
+
renderFileContent(data);
|
|
1600
|
+
} else {
|
|
1601
|
+
tree.innerHTML = `<div class="empty">${T.filesNotFound}</div>`;
|
|
1602
|
+
content.innerHTML = '';
|
|
1603
|
+
}
|
|
1604
|
+
} catch (e) {
|
|
1605
|
+
tree.innerHTML = `<div class="empty">${T.error}: ${esc(e.message)}</div>`;
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
// Load a parent dir listing into the tree without changing filesPath
|
|
1610
|
+
// (which we want to stay on the file the user opened so the breadcrumb
|
|
1611
|
+
// and the up-button still target the right place).
|
|
1612
|
+
async function loadDirOnly(path) {
|
|
1613
|
+
const tree = document.getElementById('files-tree');
|
|
1614
|
+
try {
|
|
1615
|
+
const qs = new URLSearchParams({ agent: filesAgent, path });
|
|
1616
|
+
const data = await api('/api/workspace-files?' + qs.toString());
|
|
1617
|
+
if (data.type !== 'dir') return;
|
|
1618
|
+
tree.innerHTML = `
|
|
1619
|
+
<table>
|
|
1620
|
+
<thead><tr><th>${T.filesPath}</th><th>${T.filesSize}</th><th>${T.filesMtime}</th></tr></thead>
|
|
1621
|
+
<tbody>
|
|
1622
|
+
${data.entries.map(e => `
|
|
1623
|
+
<tr data-name="${esc(e.name)}" data-isdir="${e.isDir ? '1' : '0'}" style="cursor:pointer">
|
|
1624
|
+
<td>${e.isDir ? '📁 ' : '📄 '}${esc(e.name)}${e.broken ? ' ⚠' : ''}</td>
|
|
1625
|
+
<td>${e.isDir ? '-' : fmtBytes(e.size)}</td>
|
|
1626
|
+
<td>${e.mtime ? fmtTime(e.mtime) : '-'}</td>
|
|
1627
|
+
</tr>
|
|
1628
|
+
`).join('')}
|
|
1629
|
+
</tbody>
|
|
1630
|
+
</table>
|
|
1631
|
+
`;
|
|
1632
|
+
tree.querySelectorAll('tr[data-name]').forEach(tr => {
|
|
1633
|
+
tr.addEventListener('click', () => {
|
|
1634
|
+
const name = tr.dataset.name;
|
|
1635
|
+
const next = path ? path + '/' + name : name;
|
|
1636
|
+
loadFiles(next);
|
|
1637
|
+
});
|
|
1638
|
+
});
|
|
1639
|
+
} catch { /* keep prior tree */ }
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
function renderFileContent(data) {
|
|
1643
|
+
const content = document.getElementById('files-content');
|
|
1644
|
+
const banner = data.truncated ? `<div class="empty">${T.filesTruncated}</div>` : '';
|
|
1645
|
+
const binary = data.encoding === 'base64' ? `<div class="empty">${T.filesBinary}</div>` : '';
|
|
1646
|
+
// Editable iff text + not truncated (truncated edits would discard
|
|
1647
|
+
// bytes past the 1 MiB cap, which is a footgun). Both checks here.
|
|
1648
|
+
const canEdit = data.encoding === 'utf-8' && !data.truncated;
|
|
1649
|
+
content.innerHTML = `
|
|
1650
|
+
<div style="margin-bottom:8px;display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
|
1651
|
+
<code>${esc(data.path)}</code>
|
|
1652
|
+
<span style="color:var(--muted);font-size:12px"> · ${fmtBytes(data.size)} · ${fmtTime(data.mtime)}</span>
|
|
1653
|
+
${canEdit ? `<button class="primary" id="btn-file-edit" style="margin-left:auto;background:var(--primary);color:#fff;border-color:var(--primary)">${T.filesEdit}</button>` : ''}
|
|
1654
|
+
</div>
|
|
1655
|
+
${banner}${binary}
|
|
1656
|
+
<pre id="file-pre" style="max-height:55vh;overflow:auto;background:var(--card);border:1px solid var(--border);padding:8px;border-radius:6px">${esc(data.content)}</pre>
|
|
1657
|
+
`;
|
|
1658
|
+
if (canEdit) {
|
|
1659
|
+
document.getElementById('btn-file-edit').onclick = () => enterFileEditMode(data);
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
function enterFileEditMode(data) {
|
|
1664
|
+
const content = document.getElementById('files-content');
|
|
1665
|
+
// Stash original so Cancel can roll back without re-fetching.
|
|
1666
|
+
content.innerHTML = `
|
|
1667
|
+
<div style="margin-bottom:8px;display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
|
1668
|
+
<code>${esc(data.path)}</code>
|
|
1669
|
+
<span style="color:var(--muted);font-size:12px"> · ${T.filesEditing}</span>
|
|
1670
|
+
<span style="margin-left:auto;display:flex;gap:6px">
|
|
1671
|
+
<button id="btn-file-cancel">${T.filesCancel}</button>
|
|
1672
|
+
<button id="btn-file-save" class="primary" style="background:var(--primary);color:#fff;border-color:var(--primary)">${T.filesSave}</button>
|
|
1673
|
+
</span>
|
|
1674
|
+
</div>
|
|
1675
|
+
<textarea id="file-editor" spellcheck="false"
|
|
1676
|
+
style="width:100%;min-height:55vh;font-family:'SF Mono',Menlo,Consolas,monospace;font-size:13px;background:var(--card);color:var(--fg);border:1px solid var(--border);border-radius:6px;padding:8px;outline:none;resize:vertical"></textarea>
|
|
1677
|
+
`;
|
|
1678
|
+
const ta = document.getElementById('file-editor');
|
|
1679
|
+
ta.value = data.content;
|
|
1680
|
+
ta.focus();
|
|
1681
|
+
document.getElementById('btn-file-cancel').onclick = () => renderFileContent(data);
|
|
1682
|
+
document.getElementById('btn-file-save').onclick = async () => {
|
|
1683
|
+
const newContent = ta.value;
|
|
1684
|
+
const saveBtn = document.getElementById('btn-file-save');
|
|
1685
|
+
saveBtn.disabled = true;
|
|
1686
|
+
saveBtn.textContent = T.filesSaving;
|
|
1687
|
+
try {
|
|
1688
|
+
// Bypass api() helper here so we can surface the server's
|
|
1689
|
+
// structured error body (e.g. "Content exceeds 1 MiB cap")
|
|
1690
|
+
// rather than just the HTTP statusText.
|
|
1691
|
+
const qs = new URLSearchParams({ agent: data.agent, path: data.path });
|
|
1692
|
+
const res = await fetch('/api/workspace-files?' + qs.toString(), {
|
|
1693
|
+
method: 'PUT',
|
|
1694
|
+
headers: { 'X-IM-Hub-Token': TOKEN, 'Content-Type': 'application/json' },
|
|
1695
|
+
body: JSON.stringify({ content: newContent }),
|
|
1696
|
+
});
|
|
1697
|
+
if (!res.ok) {
|
|
1698
|
+
let msg = res.status + ' ' + res.statusText;
|
|
1699
|
+
try { const j = await res.json(); if (j && j.error) msg = j.error; } catch {}
|
|
1700
|
+
throw new Error(msg);
|
|
1701
|
+
}
|
|
1702
|
+
const r = await res.json();
|
|
1703
|
+
// Re-render from the server's authoritative reply (size/mtime
|
|
1704
|
+
// change on success) rather than trusting the local string.
|
|
1705
|
+
renderFileContent({
|
|
1706
|
+
...data,
|
|
1707
|
+
content: newContent,
|
|
1708
|
+
size: r.size,
|
|
1709
|
+
mtime: r.mtime,
|
|
1710
|
+
});
|
|
1711
|
+
alert(T.filesSaved);
|
|
1712
|
+
} catch (e) {
|
|
1713
|
+
saveBtn.disabled = false;
|
|
1714
|
+
saveBtn.textContent = T.filesSave;
|
|
1715
|
+
alert(T.filesSaveFailed + ': ' + (e && e.message ? e.message : e));
|
|
1716
|
+
}
|
|
1717
|
+
};
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
document.getElementById('files-agent').addEventListener('change', (e) => {
|
|
1721
|
+
filesAgent = e.target.value;
|
|
1722
|
+
filesPath = '';
|
|
1723
|
+
loadFiles('');
|
|
1724
|
+
});
|
|
1725
|
+
document.getElementById('btn-files-refresh').onclick = () => loadFiles(filesPath);
|
|
1726
|
+
document.getElementById('btn-files-up').onclick = () => {
|
|
1727
|
+
if (!filesPath) return;
|
|
1728
|
+
const parent = filesPath.includes('/') ? filesPath.slice(0, filesPath.lastIndexOf('/')) : '';
|
|
1729
|
+
loadFiles(parent);
|
|
1730
|
+
};
|
|
1731
|
+
|
|
1732
|
+
// ============================================================
|
|
1733
|
+
// /events SSE consumer (PR-C)
|
|
1734
|
+
// ============================================================
|
|
1735
|
+
// Server pushes audit / approval / job / metrics events as they
|
|
1736
|
+
// happen. The dashboard previously polled each tab independently
|
|
1737
|
+
// every 3-5 s; now it just refreshes the visible tab when a relevant
|
|
1738
|
+
// event arrives, and keeps a small "Live" status pill in the header.
|
|
1739
|
+
//
|
|
1740
|
+
// We intentionally KEEP the existing setInterval auto-refresh as a
|
|
1741
|
+
// fallback: if SSE fails (proxy stripping, network blip, server
|
|
1742
|
+
// restart), the page still updates eventually. SSE just makes the
|
|
1743
|
+
// refresh near-instant when it works.
|
|
1744
|
+
|
|
1745
|
+
let evtSource = null;
|
|
1746
|
+
function setupSSE() {
|
|
1747
|
+
try {
|
|
1748
|
+
if (evtSource) try { evtSource.close(); } catch {}
|
|
1749
|
+
// EventSource has no header API, so token rides in the query —
|
|
1750
|
+
// matches the WS upgrade convention. Server-side handleEventsSSE
|
|
1751
|
+
// validates this before subscribing.
|
|
1752
|
+
evtSource = new EventSource(`/events?token=${encodeURIComponent(TOKEN)}`);
|
|
1753
|
+
} catch (err) {
|
|
1754
|
+
console.warn('[sse] init failed', err);
|
|
1755
|
+
return;
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
evtSource.addEventListener('hello', () => {
|
|
1759
|
+
console.debug && console.debug('[sse] connected');
|
|
1760
|
+
});
|
|
1761
|
+
|
|
1762
|
+
// When an audit lands and the Audit tab is visible, refresh it.
|
|
1763
|
+
// Same for the other types — no point repainting hidden tabs.
|
|
1764
|
+
const refreshIfVisible = (paneId, loader) => {
|
|
1765
|
+
const pane = document.getElementById(paneId);
|
|
1766
|
+
if (pane && !pane.hidden) loader();
|
|
1767
|
+
};
|
|
1768
|
+
|
|
1769
|
+
evtSource.addEventListener('audit', () => {
|
|
1770
|
+
refreshIfVisible('audit-pane', loadAudit);
|
|
1771
|
+
});
|
|
1772
|
+
evtSource.addEventListener('approval', (e) => {
|
|
1773
|
+
refreshIfVisible('approvals-pane', loadApprovals);
|
|
1774
|
+
// Approval count is also surfaced as a small badge on the tab
|
|
1775
|
+
// (best-effort — silently skip if any field is missing).
|
|
1776
|
+
try {
|
|
1777
|
+
const data = JSON.parse(e.data);
|
|
1778
|
+
if (data.phase === 'requested') flashTabBadge('tab-approvals');
|
|
1779
|
+
} catch {}
|
|
1780
|
+
});
|
|
1781
|
+
evtSource.addEventListener('job', () => {
|
|
1782
|
+
refreshIfVisible('jobs-pane', loadJobs);
|
|
1783
|
+
});
|
|
1784
|
+
evtSource.addEventListener('metrics', () => {
|
|
1785
|
+
refreshIfVisible('health-pane', loadHealth);
|
|
1786
|
+
});
|
|
1787
|
+
|
|
1788
|
+
evtSource.onerror = () => {
|
|
1789
|
+
console.warn('[sse] connection error — EventSource will auto-reconnect');
|
|
1790
|
+
};
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
/**
|
|
1794
|
+
* Pulse a tab's text briefly to draw the eye to a new event there.
|
|
1795
|
+
* Cheap CSS-class flash, auto-clears after ~1.2 s. Uses a
|
|
1796
|
+
* dynamically-injected <style> on first call so we don't need to
|
|
1797
|
+
* touch the page-level <style> block.
|
|
1798
|
+
*/
|
|
1799
|
+
let _flashStyleInjected = false;
|
|
1800
|
+
function flashTabBadge(tabId) {
|
|
1801
|
+
const el = document.getElementById(tabId);
|
|
1802
|
+
if (!el) return;
|
|
1803
|
+
if (!_flashStyleInjected) {
|
|
1804
|
+
const s = document.createElement('style');
|
|
1805
|
+
s.textContent = `
|
|
1806
|
+
@keyframes imhubFlash {
|
|
1807
|
+
0% { background: var(--primary); color: #fff; }
|
|
1808
|
+
100% { background: transparent; color: inherit; }
|
|
1809
|
+
}
|
|
1810
|
+
.imhub-flash { animation: imhubFlash 1.2s ease-out; }
|
|
1811
|
+
`;
|
|
1812
|
+
document.head.appendChild(s);
|
|
1813
|
+
_flashStyleInjected = true;
|
|
1814
|
+
}
|
|
1815
|
+
el.classList.remove('imhub-flash');
|
|
1816
|
+
// Force reflow so the animation re-triggers on rapid events.
|
|
1817
|
+
void el.offsetWidth;
|
|
1818
|
+
el.classList.add('imhub-flash');
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
setupSSE();
|
|
1822
|
+
|
|
1823
|
+
// Initial load
|
|
1824
|
+
loadJobs();
|
|
1825
|
+
</script>
|
|
1826
|
+
</body>
|
|
1827
|
+
</html>
|