@wopr-network/platform-ui-core 1.0.0

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.
Files changed (543) hide show
  1. package/.env.paperclip +18 -0
  2. package/.env.wopr +18 -0
  3. package/README.md +36 -0
  4. package/biome.json +52 -0
  5. package/next.config.ts +45 -0
  6. package/package.json +84 -0
  7. package/postcss.config.mjs +7 -0
  8. package/public/file.svg +1 -0
  9. package/public/globe.svg +1 -0
  10. package/public/window.svg +1 -0
  11. package/src/__tests__/__snapshots__/layout-snapshots.test.tsx.snap +741 -0
  12. package/src/__tests__/account-page-redirect.test.tsx +73 -0
  13. package/src/__tests__/account-switcher.test.tsx +85 -0
  14. package/src/__tests__/activity-page.test.tsx +176 -0
  15. package/src/__tests__/add-payment-method-dialog.test.tsx +160 -0
  16. package/src/__tests__/admin-api.test.ts +244 -0
  17. package/src/__tests__/admin-gpu-api.test.ts +188 -0
  18. package/src/__tests__/admin-guard.test.tsx +79 -0
  19. package/src/__tests__/admin-marketplace-api.test.ts +179 -0
  20. package/src/__tests__/admin-middleware.test.ts +157 -0
  21. package/src/__tests__/admin-tenant-table.test.tsx +95 -0
  22. package/src/__tests__/affiliate-dashboard.test.tsx +178 -0
  23. package/src/__tests__/api-401-redirect.test.ts +78 -0
  24. package/src/__tests__/api-client.test.ts +316 -0
  25. package/src/__tests__/api-config.test.ts +89 -0
  26. package/src/__tests__/api-control-instance.test.ts +69 -0
  27. package/src/__tests__/api-fleet-resources.test.ts +52 -0
  28. package/src/__tests__/api-fleet-trpc.test.ts +252 -0
  29. package/src/__tests__/api-get-instance-config.test.ts +41 -0
  30. package/src/__tests__/api-null-guards.test.ts +244 -0
  31. package/src/__tests__/api-rename-instance.test.ts +60 -0
  32. package/src/__tests__/api-update-instance-config.test.ts +60 -0
  33. package/src/__tests__/audit-log-table-pagination.test.tsx +136 -0
  34. package/src/__tests__/auth-client.test.ts +87 -0
  35. package/src/__tests__/auth-password-reset.test.tsx +435 -0
  36. package/src/__tests__/auth-redirect.test.tsx +60 -0
  37. package/src/__tests__/auth.test.tsx +269 -0
  38. package/src/__tests__/auto-topup-card.test.tsx +257 -0
  39. package/src/__tests__/backups-tab.test.tsx +221 -0
  40. package/src/__tests__/billing-byok-callout.test.tsx +76 -0
  41. package/src/__tests__/billing-layout-nav-hidden.test.tsx +47 -0
  42. package/src/__tests__/billing-payment-org-invoices.test.tsx +123 -0
  43. package/src/__tests__/billing.test.tsx +509 -0
  44. package/src/__tests__/bot-settings/resources-tab.test.tsx +119 -0
  45. package/src/__tests__/bot-settings/storage-tab.test.tsx +80 -0
  46. package/src/__tests__/bot-settings/vps-info-panel.test.tsx +108 -0
  47. package/src/__tests__/bot-settings/vps-upgrade-card.test.tsx +52 -0
  48. package/src/__tests__/bot-settings-data-control.test.ts +49 -0
  49. package/src/__tests__/bot-settings-restart.test.tsx +149 -0
  50. package/src/__tests__/bot-settings.test.tsx +678 -0
  51. package/src/__tests__/brand.test.ts +335 -0
  52. package/src/__tests__/buy-credits-panel.test.tsx +249 -0
  53. package/src/__tests__/buy-crypto-credits-panel.test.tsx +178 -0
  54. package/src/__tests__/capability-conflicts.test.ts +88 -0
  55. package/src/__tests__/capability-resolver.test.tsx +173 -0
  56. package/src/__tests__/changeset-detail.test.tsx +156 -0
  57. package/src/__tests__/channel-setup-logger.test.ts +22 -0
  58. package/src/__tests__/channel-setup-toast.test.tsx +60 -0
  59. package/src/__tests__/channel-wizard.test.tsx +505 -0
  60. package/src/__tests__/chat/ambient-dot.test.tsx +35 -0
  61. package/src/__tests__/chat/chat-input.test.tsx +78 -0
  62. package/src/__tests__/chat/chat-message.test.tsx +45 -0
  63. package/src/__tests__/chat/chat-panel.test.tsx +111 -0
  64. package/src/__tests__/chat/chat-widget.test.tsx +82 -0
  65. package/src/__tests__/chat-store.test.ts +87 -0
  66. package/src/__tests__/command-center.test.tsx +246 -0
  67. package/src/__tests__/compliance-retention-edit.test.tsx +134 -0
  68. package/src/__tests__/coupon-input.test.tsx +119 -0
  69. package/src/__tests__/create-instance.test.tsx +96 -0
  70. package/src/__tests__/create-org-wizard.test.tsx +200 -0
  71. package/src/__tests__/credit-balance.test.tsx +103 -0
  72. package/src/__tests__/credits.test.tsx +376 -0
  73. package/src/__tests__/csrf-middleware.test.ts +198 -0
  74. package/src/__tests__/degraded-state-banner.test.tsx +130 -0
  75. package/src/__tests__/dividend-calculator.test.tsx +20 -0
  76. package/src/__tests__/dividend-stats.test.tsx +64 -0
  77. package/src/__tests__/dividend.test.tsx +169 -0
  78. package/src/__tests__/dockerfile.test.ts +110 -0
  79. package/src/__tests__/email-verification-banner.test.tsx +64 -0
  80. package/src/__tests__/env-example.test.ts +25 -0
  81. package/src/__tests__/error-boundaries.test.tsx +64 -0
  82. package/src/__tests__/fetch-pricing.test.ts +121 -0
  83. package/src/__tests__/field-oauth.test.tsx +302 -0
  84. package/src/__tests__/fixtures/mock-manifests-data.js +372 -0
  85. package/src/__tests__/fixtures/mock-manifests.ts +24 -0
  86. package/src/__tests__/fleet-health-timestamp.test.tsx +101 -0
  87. package/src/__tests__/fleet-health-update.test.tsx +83 -0
  88. package/src/__tests__/format-credit.test.ts +58 -0
  89. package/src/__tests__/gpu-dashboard.test.tsx +236 -0
  90. package/src/__tests__/hosted-usage-date-range.test.tsx +54 -0
  91. package/src/__tests__/instance-detail.test.tsx +571 -0
  92. package/src/__tests__/instance-list.test.tsx +230 -0
  93. package/src/__tests__/landing-hero.test.tsx +27 -0
  94. package/src/__tests__/landing-nav.test.tsx +24 -0
  95. package/src/__tests__/layout-snapshots.test.tsx +167 -0
  96. package/src/__tests__/logger.test.ts +54 -0
  97. package/src/__tests__/login-page-redirect.test.tsx +142 -0
  98. package/src/__tests__/manifest-validation.test.ts +126 -0
  99. package/src/__tests__/marketplace-admin.test.tsx +151 -0
  100. package/src/__tests__/marketplace.test.tsx +609 -0
  101. package/src/__tests__/merge-api-rates.test.ts +70 -0
  102. package/src/__tests__/middleware.test.ts +690 -0
  103. package/src/__tests__/network-page.test.tsx +100 -0
  104. package/src/__tests__/next-config-headers.test.ts +28 -0
  105. package/src/__tests__/not-found.test.tsx +26 -0
  106. package/src/__tests__/notifications.test.tsx +128 -0
  107. package/src/__tests__/oauth-buttons.test.tsx +101 -0
  108. package/src/__tests__/oauth-error-mapping.test.tsx +97 -0
  109. package/src/__tests__/observability.test.tsx +541 -0
  110. package/src/__tests__/onboarding-data.test.ts +363 -0
  111. package/src/__tests__/onboarding-page.test.tsx +113 -0
  112. package/src/__tests__/onboarding-store.test.ts +121 -0
  113. package/src/__tests__/org-billing-api.test.tsx +70 -0
  114. package/src/__tests__/org-billing-null-guards.test.ts +64 -0
  115. package/src/__tests__/org-billing-page.test.tsx +124 -0
  116. package/src/__tests__/plugin-definition.test.ts +43 -0
  117. package/src/__tests__/plugin-install-flow.test.tsx +535 -0
  118. package/src/__tests__/plugin-registry.test.tsx +475 -0
  119. package/src/__tests__/plugin-setup/setup-chat-panel.test.ts +142 -0
  120. package/src/__tests__/plugin-setup/use-plugin-setup-chat.test.ts +49 -0
  121. package/src/__tests__/plugin-tool-definitions.test.ts +51 -0
  122. package/src/__tests__/plugin-tool-sync.test.ts +59 -0
  123. package/src/__tests__/portfolio-chart.test.tsx +24 -0
  124. package/src/__tests__/pricing.test.tsx +107 -0
  125. package/src/__tests__/promotion-form.test.tsx +180 -0
  126. package/src/__tests__/promotions-list.test.tsx +194 -0
  127. package/src/__tests__/provider-key-api.test.ts +134 -0
  128. package/src/__tests__/resend-verification-button.test.tsx +104 -0
  129. package/src/__tests__/sanitize-redirect-url.test.ts +47 -0
  130. package/src/__tests__/secrets-audit-pagination.test.tsx +139 -0
  131. package/src/__tests__/settings.test.tsx +937 -0
  132. package/src/__tests__/setup-checklist.test.tsx +274 -0
  133. package/src/__tests__/setup.ts +82 -0
  134. package/src/__tests__/smoke.test.tsx +10 -0
  135. package/src/__tests__/snapshot-api.test.ts +104 -0
  136. package/src/__tests__/status-api.test.ts +46 -0
  137. package/src/__tests__/status-badge.test.tsx +33 -0
  138. package/src/__tests__/status-colors.test.ts +83 -0
  139. package/src/__tests__/status-page.test.tsx +86 -0
  140. package/src/__tests__/step-superpowers.test.tsx +218 -0
  141. package/src/__tests__/story-sections.test.tsx +24 -0
  142. package/src/__tests__/superpower-content-sanitize.test.tsx +87 -0
  143. package/src/__tests__/superpower-content.test.tsx +44 -0
  144. package/src/__tests__/suspension-banner.test.tsx +140 -0
  145. package/src/__tests__/tenant-context.test.tsx +146 -0
  146. package/src/__tests__/tenant-keys-api.test.ts +114 -0
  147. package/src/__tests__/tenant-table-pagination.test.tsx +124 -0
  148. package/src/__tests__/terminal-log-cleanup.test.tsx +51 -0
  149. package/src/__tests__/terminal-sequence.test.tsx +28 -0
  150. package/src/__tests__/transaction-history.test.tsx +325 -0
  151. package/src/__tests__/trpc-types.test.ts +102 -0
  152. package/src/__tests__/use-capability-meta.test.ts +161 -0
  153. package/src/__tests__/use-chat.test.ts +616 -0
  154. package/src/__tests__/use-has-org.test.ts +44 -0
  155. package/src/__tests__/use-image-status.test.ts +77 -0
  156. package/src/__tests__/use-pagination-params.test.ts +88 -0
  157. package/src/__tests__/use-plugin-setup-chat-stale-closure.test.ts +53 -0
  158. package/src/__tests__/use-webmcp.test.ts +119 -0
  159. package/src/__tests__/validate-elevenlabs-key.test.ts +95 -0
  160. package/src/__tests__/validate-redirect-url.test.ts +61 -0
  161. package/src/__tests__/verify-page.test.tsx +140 -0
  162. package/src/__tests__/verify-redirect.test.tsx +41 -0
  163. package/src/__tests__/verify-result-banner.test.tsx +66 -0
  164. package/src/__tests__/webmcp-feature-detect.test.ts +54 -0
  165. package/src/__tests__/webmcp-hook.test.tsx +72 -0
  166. package/src/__tests__/webmcp-marketplace-onboarding-tools.test.ts +185 -0
  167. package/src/__tests__/webmcp-register.test.ts +103 -0
  168. package/src/__tests__/webmcp-set-provider.test.ts +47 -0
  169. package/src/__tests__/webmcp-tools.test.ts +348 -0
  170. package/src/app/(auth)/error.tsx +72 -0
  171. package/src/app/(auth)/forgot-password/page.tsx +137 -0
  172. package/src/app/(auth)/layout.tsx +14 -0
  173. package/src/app/(auth)/loading.tsx +26 -0
  174. package/src/app/(auth)/login/page.tsx +188 -0
  175. package/src/app/(auth)/reset-password/page.tsx +169 -0
  176. package/src/app/(auth)/signup/page.tsx +309 -0
  177. package/src/app/(dashboard)/billing/credits/page.tsx +209 -0
  178. package/src/app/(dashboard)/billing/error.tsx +72 -0
  179. package/src/app/(dashboard)/billing/layout.tsx +73 -0
  180. package/src/app/(dashboard)/billing/loading.tsx +41 -0
  181. package/src/app/(dashboard)/billing/payment/page.tsx +639 -0
  182. package/src/app/(dashboard)/billing/plans/page.tsx +58 -0
  183. package/src/app/(dashboard)/billing/referrals/page.tsx +7 -0
  184. package/src/app/(dashboard)/billing/usage/hosted/page.tsx +348 -0
  185. package/src/app/(dashboard)/billing/usage/page.tsx +663 -0
  186. package/src/app/(dashboard)/changesets/[id]/changeset-detail-client.tsx +400 -0
  187. package/src/app/(dashboard)/changesets/[id]/error.tsx +57 -0
  188. package/src/app/(dashboard)/changesets/[id]/loading.tsx +23 -0
  189. package/src/app/(dashboard)/changesets/[id]/page.tsx +10 -0
  190. package/src/app/(dashboard)/changesets/error.tsx +72 -0
  191. package/src/app/(dashboard)/changesets/page.tsx +10 -0
  192. package/src/app/(dashboard)/chat/page.tsx +74 -0
  193. package/src/app/(dashboard)/dashboard/bots/[id]/settings/page.tsx +10 -0
  194. package/src/app/(dashboard)/dashboard/network/page.tsx +97 -0
  195. package/src/app/(dashboard)/dashboard/page.tsx +13 -0
  196. package/src/app/(dashboard)/error.tsx +72 -0
  197. package/src/app/(dashboard)/layout.tsx +113 -0
  198. package/src/app/(dashboard)/loading.tsx +27 -0
  199. package/src/app/(dashboard)/marketplace/[plugin]/page.tsx +548 -0
  200. package/src/app/(dashboard)/marketplace/error.tsx +72 -0
  201. package/src/app/(dashboard)/marketplace/loading.tsx +27 -0
  202. package/src/app/(dashboard)/marketplace/page.tsx +268 -0
  203. package/src/app/(dashboard)/not-found.tsx +46 -0
  204. package/src/app/(dashboard)/onboarding/page.tsx +267 -0
  205. package/src/app/(dashboard)/settings/account/page.tsx +132 -0
  206. package/src/app/(dashboard)/settings/activity/page.tsx +280 -0
  207. package/src/app/(dashboard)/settings/api-keys/page.tsx +530 -0
  208. package/src/app/(dashboard)/settings/brain/page.tsx +412 -0
  209. package/src/app/(dashboard)/settings/error.tsx +72 -0
  210. package/src/app/(dashboard)/settings/layout.tsx +114 -0
  211. package/src/app/(dashboard)/settings/loading.tsx +31 -0
  212. package/src/app/(dashboard)/settings/notifications/page.tsx +216 -0
  213. package/src/app/(dashboard)/settings/org/page.tsx +617 -0
  214. package/src/app/(dashboard)/settings/profile/page.tsx +510 -0
  215. package/src/app/(dashboard)/settings/providers/page.tsx +842 -0
  216. package/src/app/(dashboard)/settings/secrets/page.tsx +658 -0
  217. package/src/app/(dashboard)/settings/security/page.tsx +1133 -0
  218. package/src/app/admin/accounting/loading.tsx +32 -0
  219. package/src/app/admin/accounting/page.tsx +5 -0
  220. package/src/app/admin/affiliates/loading.tsx +32 -0
  221. package/src/app/admin/affiliates/page.tsx +5 -0
  222. package/src/app/admin/audit/loading.tsx +32 -0
  223. package/src/app/admin/audit/page.tsx +5 -0
  224. package/src/app/admin/billing-health/loading.tsx +17 -0
  225. package/src/app/admin/billing-health/page.tsx +10 -0
  226. package/src/app/admin/compliance/page.tsx +5 -0
  227. package/src/app/admin/error.tsx +72 -0
  228. package/src/app/admin/gpu/loading.tsx +38 -0
  229. package/src/app/admin/gpu/page.tsx +5 -0
  230. package/src/app/admin/incidents/page.tsx +10 -0
  231. package/src/app/admin/inference/loading.tsx +32 -0
  232. package/src/app/admin/inference/page.tsx +5 -0
  233. package/src/app/admin/layout.tsx +44 -0
  234. package/src/app/admin/loading.tsx +32 -0
  235. package/src/app/admin/marketplace/loading.tsx +32 -0
  236. package/src/app/admin/marketplace/page.tsx +5 -0
  237. package/src/app/admin/migrations/loading.tsx +22 -0
  238. package/src/app/admin/migrations/page.tsx +5 -0
  239. package/src/app/admin/onboarding/loading.tsx +18 -0
  240. package/src/app/admin/onboarding/page.tsx +5 -0
  241. package/src/app/admin/promotions/[id]/edit/loading.tsx +16 -0
  242. package/src/app/admin/promotions/[id]/edit/page.tsx +56 -0
  243. package/src/app/admin/promotions/[id]/loading.tsx +15 -0
  244. package/src/app/admin/promotions/[id]/page.tsx +311 -0
  245. package/src/app/admin/promotions/loading.tsx +21 -0
  246. package/src/app/admin/promotions/new/loading.tsx +16 -0
  247. package/src/app/admin/promotions/new/page.tsx +12 -0
  248. package/src/app/admin/promotions/page.tsx +266 -0
  249. package/src/app/admin/rate-overrides/loading.tsx +17 -0
  250. package/src/app/admin/rate-overrides/page.tsx +290 -0
  251. package/src/app/admin/roles/loading.tsx +27 -0
  252. package/src/app/admin/roles/page.tsx +5 -0
  253. package/src/app/admin/tenants/loading.tsx +32 -0
  254. package/src/app/admin/tenants/page.tsx +5 -0
  255. package/src/app/apple-icon.tsx +32 -0
  256. package/src/app/auth/callback/[provider]/page.tsx +104 -0
  257. package/src/app/auth/verify/page.tsx +224 -0
  258. package/src/app/channels/error.tsx +72 -0
  259. package/src/app/channels/loading.tsx +29 -0
  260. package/src/app/channels/page.tsx +262 -0
  261. package/src/app/channels/setup/[plugin]/page.tsx +136 -0
  262. package/src/app/error.tsx +72 -0
  263. package/src/app/favicon.ico +0 -0
  264. package/src/app/fleet/error.tsx +72 -0
  265. package/src/app/fleet/health/page.tsx +9 -0
  266. package/src/app/fleet/layout.tsx +14 -0
  267. package/src/app/fleet/loading.tsx +33 -0
  268. package/src/app/fleet/page.tsx +5 -0
  269. package/src/app/global-error.tsx +96 -0
  270. package/src/app/globals.css +251 -0
  271. package/src/app/icon.svg +4 -0
  272. package/src/app/instances/[id]/instance-detail-client.tsx +1298 -0
  273. package/src/app/instances/[id]/page.tsx +10 -0
  274. package/src/app/instances/error.tsx +72 -0
  275. package/src/app/instances/instance-list-client.tsx +540 -0
  276. package/src/app/instances/loading.tsx +33 -0
  277. package/src/app/instances/new/create-instance-client.tsx +377 -0
  278. package/src/app/instances/new/page.tsx +9 -0
  279. package/src/app/instances/page.tsx +9 -0
  280. package/src/app/layout.tsx +83 -0
  281. package/src/app/not-found.tsx +38 -0
  282. package/src/app/og/route.tsx +50 -0
  283. package/src/app/page.tsx +39 -0
  284. package/src/app/plugins/error.tsx +72 -0
  285. package/src/app/plugins/layout.tsx +14 -0
  286. package/src/app/plugins/loading.tsx +30 -0
  287. package/src/app/plugins/page.tsx +555 -0
  288. package/src/app/pricing/error.tsx +72 -0
  289. package/src/app/pricing/loading.tsx +25 -0
  290. package/src/app/pricing/page.tsx +20 -0
  291. package/src/app/privacy/page.tsx +406 -0
  292. package/src/app/robots.ts +9 -0
  293. package/src/app/sitemap.ts +11 -0
  294. package/src/app/status/error.tsx +72 -0
  295. package/src/app/status/loading.tsx +21 -0
  296. package/src/app/status/page.tsx +20 -0
  297. package/src/app/terms/page.tsx +414 -0
  298. package/src/components/account-switcher.tsx +82 -0
  299. package/src/components/admin/accounting-dashboard.tsx +190 -0
  300. package/src/components/admin/admin-guard.tsx +36 -0
  301. package/src/components/admin/admin-nav.tsx +71 -0
  302. package/src/components/admin/affiliate-dashboard.tsx +564 -0
  303. package/src/components/admin/audit-log-table.tsx +336 -0
  304. package/src/components/admin/billing-health-dashboard.test.tsx +40 -0
  305. package/src/components/admin/billing-health-dashboard.tsx +416 -0
  306. package/src/components/admin/bulk-actions-bar.test.tsx +92 -0
  307. package/src/components/admin/bulk-actions-bar.tsx +80 -0
  308. package/src/components/admin/bulk-export-dialog.test.tsx +75 -0
  309. package/src/components/admin/bulk-export-dialog.tsx +189 -0
  310. package/src/components/admin/bulk-grant-dialog.test.tsx +81 -0
  311. package/src/components/admin/bulk-grant-dialog.tsx +147 -0
  312. package/src/components/admin/bulk-preview-dialog.test.tsx +72 -0
  313. package/src/components/admin/bulk-preview-dialog.tsx +106 -0
  314. package/src/components/admin/bulk-reactivate-dialog.test.tsx +51 -0
  315. package/src/components/admin/bulk-reactivate-dialog.tsx +55 -0
  316. package/src/components/admin/bulk-select-all-banner.test.tsx +36 -0
  317. package/src/components/admin/bulk-select-all-banner.tsx +44 -0
  318. package/src/components/admin/bulk-suspend-dialog.test.tsx +77 -0
  319. package/src/components/admin/bulk-suspend-dialog.tsx +129 -0
  320. package/src/components/admin/bulk-undo-toast.test.tsx +66 -0
  321. package/src/components/admin/bulk-undo-toast.tsx +121 -0
  322. package/src/components/admin/compliance-dashboard.tsx +1341 -0
  323. package/src/components/admin/gpu-dashboard.tsx +552 -0
  324. package/src/components/admin/grant-credits-dialog.tsx +121 -0
  325. package/src/components/admin/incident-dashboard.test.tsx +44 -0
  326. package/src/components/admin/incident-dashboard.tsx +717 -0
  327. package/src/components/admin/inference-dashboard.tsx +415 -0
  328. package/src/components/admin/marketplace-admin.tsx +765 -0
  329. package/src/components/admin/migrations-dashboard.tsx +404 -0
  330. package/src/components/admin/onboarding-dashboard.tsx +422 -0
  331. package/src/components/admin/promotions/promotion-form.tsx +440 -0
  332. package/src/components/admin/roles-dashboard.tsx +278 -0
  333. package/src/components/admin/suspend-dialog.tsx +98 -0
  334. package/src/components/admin/tenant-notes-panel.tsx +134 -0
  335. package/src/components/admin/tenant-row-actions.tsx +78 -0
  336. package/src/components/admin/tenant-table.tsx +339 -0
  337. package/src/components/auth/auth-error.tsx +22 -0
  338. package/src/components/auth/auth-redirect.tsx +18 -0
  339. package/src/components/auth/auth-shell.tsx +25 -0
  340. package/src/components/auth/email-verification-banner.tsx +25 -0
  341. package/src/components/auth/email-verification-result-banner.tsx +70 -0
  342. package/src/components/auth/resend-verification-button.tsx +94 -0
  343. package/src/components/auth/wopr-wordmark.tsx +19 -0
  344. package/src/components/billing/add-payment-method-dialog.tsx +267 -0
  345. package/src/components/billing/affiliate-dashboard.tsx +300 -0
  346. package/src/components/billing/auto-topup-card.tsx +432 -0
  347. package/src/components/billing/buy-credits-panel.tsx +180 -0
  348. package/src/components/billing/buy-crypto-credits-panel.tsx +96 -0
  349. package/src/components/billing/byok-callout.tsx +87 -0
  350. package/src/components/billing/coupon-input.tsx +86 -0
  351. package/src/components/billing/credit-balance.tsx +79 -0
  352. package/src/components/billing/degraded-state-banner.tsx +95 -0
  353. package/src/components/billing/dividend-banner.tsx +97 -0
  354. package/src/components/billing/dividend-eligibility.tsx +86 -0
  355. package/src/components/billing/dividend-pool-stats.tsx +86 -0
  356. package/src/components/billing/first-dividend-dialog.tsx +109 -0
  357. package/src/components/billing/low-balance-banner.tsx +50 -0
  358. package/src/components/billing/org-billing-page.tsx +360 -0
  359. package/src/components/billing/suspension-banner.tsx +53 -0
  360. package/src/components/billing/transaction-history.tsx +239 -0
  361. package/src/components/bot-settings/__tests__/bot-settings-client.test.tsx +205 -0
  362. package/src/components/bot-settings/backups-tab.tsx +377 -0
  363. package/src/components/bot-settings/bot-settings-client.tsx +1712 -0
  364. package/src/components/bot-settings/resources-tab.tsx +203 -0
  365. package/src/components/bot-settings/storage-tab.tsx +248 -0
  366. package/src/components/bot-settings/vps-info-panel.tsx +132 -0
  367. package/src/components/bot-settings/vps-upgrade-card.tsx +110 -0
  368. package/src/components/capability/CapabilityResolver.tsx +113 -0
  369. package/src/components/channel-wizard/field-interactive.tsx +48 -0
  370. package/src/components/channel-wizard/field-oauth.tsx +181 -0
  371. package/src/components/channel-wizard/field-paste.tsx +47 -0
  372. package/src/components/channel-wizard/field-qr.tsx +302 -0
  373. package/src/components/channel-wizard/index.ts +6 -0
  374. package/src/components/channel-wizard/step-renderer.tsx +103 -0
  375. package/src/components/channel-wizard/wizard.tsx +200 -0
  376. package/src/components/chat/ambient-dot.tsx +32 -0
  377. package/src/components/chat/chat-input.tsx +56 -0
  378. package/src/components/chat/chat-message.tsx +36 -0
  379. package/src/components/chat/chat-panel.tsx +138 -0
  380. package/src/components/chat/chat-widget.tsx +41 -0
  381. package/src/components/chat/index.ts +5 -0
  382. package/src/components/dashboard/command-center.tsx +614 -0
  383. package/src/components/instances/friends-tab.test.tsx +265 -0
  384. package/src/components/instances/friends-tab.tsx +721 -0
  385. package/src/components/landing/hero.tsx +53 -0
  386. package/src/components/landing/landing-nav.tsx +21 -0
  387. package/src/components/landing/landing-page.tsx +71 -0
  388. package/src/components/landing/portfolio-chart.tsx +349 -0
  389. package/src/components/landing/story-sections.tsx +50 -0
  390. package/src/components/landing/terminal-lines.ts +99 -0
  391. package/src/components/landing/terminal-sequence.tsx +453 -0
  392. package/src/components/landing/typing-effect.tsx +43 -0
  393. package/src/components/marketplace/category-filter.tsx +61 -0
  394. package/src/components/marketplace/empty-state.tsx +61 -0
  395. package/src/components/marketplace/featured-heroes.tsx +84 -0
  396. package/src/components/marketplace/first-visit-hero.tsx +110 -0
  397. package/src/components/marketplace/index.ts +9 -0
  398. package/src/components/marketplace/install-wizard.tsx +782 -0
  399. package/src/components/marketplace/marketplace-tabs.tsx +54 -0
  400. package/src/components/marketplace/plugin-card.tsx +129 -0
  401. package/src/components/marketplace/superpower-card.tsx +104 -0
  402. package/src/components/marketplace/superpower-content.tsx +117 -0
  403. package/src/components/marketplace/terminal-search.tsx +67 -0
  404. package/src/components/oauth-buttons.tsx +75 -0
  405. package/src/components/observability/fleet-health.tsx +370 -0
  406. package/src/components/observability/health-overview.tsx +246 -0
  407. package/src/components/observability/logs-viewer.tsx +215 -0
  408. package/src/components/observability/metrics-dashboard.tsx +288 -0
  409. package/src/components/onboarding/fallback-setup.tsx +137 -0
  410. package/src/components/onboarding/index.ts +3 -0
  411. package/src/components/onboarding/setup-checklist.tsx +333 -0
  412. package/src/components/onboarding/step-superpowers.tsx +122 -0
  413. package/src/components/plugin-setup/index.ts +1 -0
  414. package/src/components/plugin-setup/setup-chat-panel.tsx +188 -0
  415. package/src/components/pricing/dividend-calculator.tsx +47 -0
  416. package/src/components/pricing/dividend-stats.tsx +117 -0
  417. package/src/components/pricing/pricing-page.tsx +229 -0
  418. package/src/components/settings/create-org-wizard.tsx +225 -0
  419. package/src/components/sidebar.tsx +202 -0
  420. package/src/components/status/status-page.tsx +209 -0
  421. package/src/components/status-badge.tsx +28 -0
  422. package/src/components/theme-provider.tsx +8 -0
  423. package/src/components/ui/alert-dialog.tsx +141 -0
  424. package/src/components/ui/badge.tsx +47 -0
  425. package/src/components/ui/banner.tsx +36 -0
  426. package/src/components/ui/button.tsx +64 -0
  427. package/src/components/ui/card.tsx +75 -0
  428. package/src/components/ui/checkbox.tsx +52 -0
  429. package/src/components/ui/collapsible.tsx +31 -0
  430. package/src/components/ui/credit-detailed.tsx +33 -0
  431. package/src/components/ui/dialog.tsx +143 -0
  432. package/src/components/ui/dropdown-menu.tsx +228 -0
  433. package/src/components/ui/form.tsx +151 -0
  434. package/src/components/ui/input.tsx +21 -0
  435. package/src/components/ui/label.tsx +21 -0
  436. package/src/components/ui/popover.tsx +74 -0
  437. package/src/components/ui/progress.tsx +28 -0
  438. package/src/components/ui/radio-group.tsx +45 -0
  439. package/src/components/ui/select.tsx +175 -0
  440. package/src/components/ui/separator.tsx +28 -0
  441. package/src/components/ui/sheet.tsx +125 -0
  442. package/src/components/ui/skeleton.tsx +15 -0
  443. package/src/components/ui/switch.tsx +35 -0
  444. package/src/components/ui/table.tsx +92 -0
  445. package/src/components/ui/tabs.tsx +81 -0
  446. package/src/components/ui/textarea.tsx +18 -0
  447. package/src/components/ui/tooltip.tsx +44 -0
  448. package/src/config/provider-docs.ts +17 -0
  449. package/src/hooks/__tests__/use-async.test.ts +127 -0
  450. package/src/hooks/__tests__/use-count-up.test.ts +129 -0
  451. package/src/hooks/__tests__/use-debounce.test.ts +105 -0
  452. package/src/hooks/__tests__/use-fleet-sse.test.ts +216 -0
  453. package/src/hooks/__tests__/use-local-storage.test.ts +74 -0
  454. package/src/hooks/__tests__/use-mobile.test.ts +86 -0
  455. package/src/hooks/__tests__/use-save-queue.test.ts +159 -0
  456. package/src/hooks/use-async.ts +54 -0
  457. package/src/hooks/use-capability-meta.ts +99 -0
  458. package/src/hooks/use-count-up.ts +23 -0
  459. package/src/hooks/use-debounce.ts +12 -0
  460. package/src/hooks/use-fleet-sse.ts +47 -0
  461. package/src/hooks/use-has-org.ts +18 -0
  462. package/src/hooks/use-image-status.ts +36 -0
  463. package/src/hooks/use-local-storage.ts +36 -0
  464. package/src/hooks/use-mobile.ts +17 -0
  465. package/src/hooks/use-page-context.ts +24 -0
  466. package/src/hooks/use-pagination-params.ts +30 -0
  467. package/src/hooks/use-plugin-registry.ts +247 -0
  468. package/src/hooks/use-plugin-setup-chat.ts +211 -0
  469. package/src/hooks/use-save-queue.ts +54 -0
  470. package/src/hooks/use-webmcp.ts +40 -0
  471. package/src/lib/__tests__/__snapshots__/pricing-data.test.ts.snap +112 -0
  472. package/src/lib/__tests__/admin-api.test.ts +487 -0
  473. package/src/lib/__tests__/api-bot-crud.test.ts +391 -0
  474. package/src/lib/__tests__/api-fetch.test.ts +196 -0
  475. package/src/lib/__tests__/bot-settings-data.test.ts +352 -0
  476. package/src/lib/__tests__/org-api.test.ts +281 -0
  477. package/src/lib/__tests__/org-billing-api.test.ts +242 -0
  478. package/src/lib/__tests__/pricing-data.test.ts +32 -0
  479. package/src/lib/__tests__/settings-api.test.ts +272 -0
  480. package/src/lib/admin-affiliate-api.ts +51 -0
  481. package/src/lib/admin-api.ts +325 -0
  482. package/src/lib/admin-compliance-api.ts +127 -0
  483. package/src/lib/admin-gpu-api.ts +82 -0
  484. package/src/lib/admin-incident-api.ts +121 -0
  485. package/src/lib/admin-inference-api.ts +47 -0
  486. package/src/lib/admin-marketplace-api.ts +97 -0
  487. package/src/lib/api-config.test.ts +111 -0
  488. package/src/lib/api-config.ts +65 -0
  489. package/src/lib/api-errors.test.ts +43 -0
  490. package/src/lib/api.ts +2011 -0
  491. package/src/lib/auth-client.ts +11 -0
  492. package/src/lib/bot-settings-data.ts +342 -0
  493. package/src/lib/brand-config.ts +145 -0
  494. package/src/lib/brand.ts +669 -0
  495. package/src/lib/changeset-api.ts +29 -0
  496. package/src/lib/changeset-types.ts +56 -0
  497. package/src/lib/channel-manifests.ts +50 -0
  498. package/src/lib/chat/chat-context.tsx +70 -0
  499. package/src/lib/chat/chat-store.ts +62 -0
  500. package/src/lib/chat/types.ts +35 -0
  501. package/src/lib/chat/use-chat.ts +255 -0
  502. package/src/lib/cost-comparison-data.test.ts +95 -0
  503. package/src/lib/cost-comparison-data.ts +54 -0
  504. package/src/lib/errors.test.ts +64 -0
  505. package/src/lib/errors.ts +52 -0
  506. package/src/lib/fetch-utils.test.ts +57 -0
  507. package/src/lib/fetch-utils.ts +25 -0
  508. package/src/lib/format-credit.test.ts +66 -0
  509. package/src/lib/format-credit.ts +24 -0
  510. package/src/lib/format.test.ts +62 -0
  511. package/src/lib/format.ts +17 -0
  512. package/src/lib/logger.ts +28 -0
  513. package/src/lib/marketplace-data.ts +346 -0
  514. package/src/lib/oauth-errors.ts +19 -0
  515. package/src/lib/onboarding-data.ts +1265 -0
  516. package/src/lib/onboarding-store.ts +233 -0
  517. package/src/lib/org-api.ts +74 -0
  518. package/src/lib/org-billing-api.ts +81 -0
  519. package/src/lib/page-prompts.test.ts +32 -0
  520. package/src/lib/page-prompts.ts +23 -0
  521. package/src/lib/plugin/index.ts +32 -0
  522. package/src/lib/plugin/tool-definitions.ts +306 -0
  523. package/src/lib/pricing-data.ts +115 -0
  524. package/src/lib/promotions-types.ts +58 -0
  525. package/src/lib/settings-api.ts +63 -0
  526. package/src/lib/status-colors.ts +38 -0
  527. package/src/lib/tenant-context.tsx +134 -0
  528. package/src/lib/trpc-types.ts +173 -0
  529. package/src/lib/trpc.tsx +86 -0
  530. package/src/lib/utils.test.ts +55 -0
  531. package/src/lib/utils.ts +18 -0
  532. package/src/lib/validate-redirect-url.ts +39 -0
  533. package/src/lib/webmcp/feature-detect.ts +13 -0
  534. package/src/lib/webmcp/marketplace-onboarding-tools.ts +202 -0
  535. package/src/lib/webmcp/register.ts +44 -0
  536. package/src/lib/webmcp/tools.ts +422 -0
  537. package/src/proxy.ts +258 -0
  538. package/src/types/missing-deps.d.ts +160 -0
  539. package/src/types/motion-dom.d.ts +162 -0
  540. package/src/types/vitest-matchers.d.ts +40 -0
  541. package/src/types/web-mcp.d.ts +22 -0
  542. package/tsconfig.json +34 -0
  543. package/vitest.config.ts +26 -0
@@ -0,0 +1,1298 @@
1
+ "use client";
2
+
3
+ import {
4
+ ArrowDownToLine,
5
+ ArrowLeft,
6
+ Check,
7
+ Loader2,
8
+ Lock,
9
+ Pencil,
10
+ Plus,
11
+ Trash2,
12
+ X,
13
+ } from "lucide-react";
14
+ import Link from "next/link";
15
+ import { useRouter, useSearchParams } from "next/navigation";
16
+ import { useCallback, useEffect, useRef, useState } from "react";
17
+ import { FriendsTab } from "@/components/instances/friends-tab";
18
+ import { HealthOverview } from "@/components/observability/health-overview";
19
+ import { LogsViewer } from "@/components/observability/logs-viewer";
20
+ import { MetricsDashboard } from "@/components/observability/metrics-dashboard";
21
+ import { StatusBadge } from "@/components/status-badge";
22
+ import {
23
+ AlertDialog,
24
+ AlertDialogAction,
25
+ AlertDialogCancel,
26
+ AlertDialogContent,
27
+ AlertDialogDescription,
28
+ AlertDialogFooter,
29
+ AlertDialogHeader,
30
+ AlertDialogTitle,
31
+ } from "@/components/ui/alert-dialog";
32
+ import { Badge } from "@/components/ui/badge";
33
+ import { Button } from "@/components/ui/button";
34
+ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
35
+ import {
36
+ Dialog,
37
+ DialogContent,
38
+ DialogDescription,
39
+ DialogFooter,
40
+ DialogHeader,
41
+ DialogTitle,
42
+ } from "@/components/ui/dialog";
43
+ import { Input } from "@/components/ui/input";
44
+ import { Separator } from "@/components/ui/separator";
45
+ import { Skeleton } from "@/components/ui/skeleton";
46
+ import { Switch } from "@/components/ui/switch";
47
+ import {
48
+ Table,
49
+ TableBody,
50
+ TableCell,
51
+ TableHead,
52
+ TableHeader,
53
+ TableRow,
54
+ } from "@/components/ui/table";
55
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
56
+ import { Textarea } from "@/components/ui/textarea";
57
+ import { useImageStatus } from "@/hooks/use-image-status";
58
+ import type { InstanceDetail, InstanceStatus, Snapshot } from "@/lib/api";
59
+ import {
60
+ controlInstance,
61
+ createSnapshot,
62
+ deleteSnapshot,
63
+ getInstance,
64
+ getInstanceSecretKeys,
65
+ listSnapshots,
66
+ pullImageUpdate,
67
+ renameInstance,
68
+ restoreSnapshot,
69
+ toggleInstancePlugin,
70
+ updateInstanceBudget,
71
+ updateInstanceConfig,
72
+ updateInstanceSecrets,
73
+ } from "@/lib/api";
74
+ import { getBrandConfig } from "@/lib/brand-config";
75
+ import { toUserMessage } from "@/lib/errors";
76
+ import { cn } from "@/lib/utils";
77
+
78
+ export function InstanceDetailClient({ instanceId }: { instanceId: string }) {
79
+ const router = useRouter();
80
+ const searchParams = useSearchParams();
81
+ const defaultTab = searchParams.get("tab") ?? "overview";
82
+ const [instance, setInstance] = useState<InstanceDetail | null>(null);
83
+ const [loading, setLoading] = useState(true);
84
+ const [error, setError] = useState<string | null>(null);
85
+ const [configText, setConfigText] = useState("");
86
+ const [actionError, setActionError] = useState<string | null>(null);
87
+ const [configStatus, setConfigStatus] = useState<"idle" | "saved" | "invalid" | "error">("idle");
88
+ const [configError, setConfigError] = useState<string | null>(null);
89
+ const [saving, setSaving] = useState(false);
90
+ const [snapshots, setSnapshots] = useState<Snapshot[]>([]);
91
+ const [snapshotsLoading, setSnapshotsLoading] = useState(false);
92
+ const [snapshotsError, setSnapshotsError] = useState<string | null>(null);
93
+ const [creating, setCreating] = useState(false);
94
+ const [confirmRestore, setConfirmRestore] = useState<Snapshot | null>(null);
95
+ const [confirmDelete, setConfirmDelete] = useState<Snapshot | null>(null);
96
+ const [restoring, setRestoring] = useState(false);
97
+ const [deleting, setDeleting] = useState(false);
98
+ const [activeTab, setActiveTab] = useState(defaultTab);
99
+ const snapshotsLoaded = useRef(false);
100
+ const [destroyOpen, setDestroyOpen] = useState(false);
101
+ const [destroyConfirmText, setDestroyConfirmText] = useState("");
102
+ const [destroying, setDestroying] = useState(false);
103
+ const { updateAvailable, error: imageStatusError } = useImageStatus(instanceId);
104
+ const [pulling, setPulling] = useState(false);
105
+ const [confirmPull, setConfirmPull] = useState(false);
106
+ const [togglingPlugin, setTogglingPlugin] = useState<string | null>(null);
107
+ const [secretKeys, setSecretKeys] = useState<string[]>([]);
108
+ const [secretValues, setSecretValues] = useState<Record<string, string>>({});
109
+ const [secretsLoading, setSecretsLoading] = useState(false);
110
+ const [secretsSaving, setSecretsSaving] = useState(false);
111
+ const [secretsStatus, setSecretsStatus] = useState<"idle" | "saved" | "error">("idle");
112
+ const [secretsError, setSecretsError] = useState<string | null>(null);
113
+ const nextSecretId = useRef(0);
114
+ const [newSecretRows, setNewSecretRows] = useState<{ id: number; key: string; value: string }[]>(
115
+ [],
116
+ );
117
+ const secretsLoaded = useRef(false);
118
+ const [renaming, setRenaming] = useState(false);
119
+ const [renameValue, setRenameValue] = useState("");
120
+ const [renameSaving, setRenameSaving] = useState(false);
121
+ const [budgetCents, setBudgetCents] = useState<number>(0);
122
+ const [budgetSaving, setBudgetSaving] = useState(false);
123
+ const [budgetError, setBudgetError] = useState<string | null>(null);
124
+
125
+ useEffect(() => {
126
+ if (!configText.trim()) return;
127
+ try {
128
+ JSON.parse(configText);
129
+ setConfigStatus((prev) => (prev === "invalid" ? "idle" : prev));
130
+ } catch {
131
+ setConfigStatus("invalid");
132
+ }
133
+ }, [configText]);
134
+
135
+ async function handleTogglePlugin(pluginId: string, enabled: boolean) {
136
+ if (!instance) return;
137
+ setActionError(null);
138
+ setTogglingPlugin(pluginId);
139
+
140
+ const previousPlugins = instance.plugins;
141
+ setInstance({
142
+ ...instance,
143
+ plugins: instance.plugins.map((p) => (p.id === pluginId ? { ...p, enabled } : p)),
144
+ });
145
+
146
+ try {
147
+ await toggleInstancePlugin(instanceId, pluginId, enabled);
148
+ } catch (err) {
149
+ setInstance((prev) => (prev ? { ...prev, plugins: previousPlugins } : prev));
150
+ setActionError(toUserMessage(err, "Failed to toggle plugin"));
151
+ } finally {
152
+ setTogglingPlugin(null);
153
+ }
154
+ }
155
+
156
+ async function handleRename() {
157
+ if (!instance || !renameValue.trim() || renameValue.trim() === instance.name) {
158
+ setRenaming(false);
159
+ return;
160
+ }
161
+ setRenameSaving(true);
162
+ const previousName = instance.name;
163
+ setInstance((prev) => (prev ? { ...prev, name: renameValue.trim() } : prev));
164
+ try {
165
+ await renameInstance(instanceId, renameValue.trim());
166
+ setRenaming(false);
167
+ } catch (err) {
168
+ setInstance((prev) => (prev ? { ...prev, name: previousName } : prev));
169
+ setActionError(toUserMessage(err, "Failed to rename instance"));
170
+ setRenaming(false);
171
+ } finally {
172
+ setRenameSaving(false);
173
+ }
174
+ }
175
+
176
+ async function handlePullUpdate() {
177
+ setConfirmPull(false);
178
+ setPulling(true);
179
+ try {
180
+ await pullImageUpdate(instanceId);
181
+ await load();
182
+ } catch (err) {
183
+ setActionError(toUserMessage(err, "Failed to pull update"));
184
+ } finally {
185
+ setPulling(false);
186
+ }
187
+ }
188
+
189
+ const load = useCallback(async () => {
190
+ setLoading(true);
191
+ setError(null);
192
+ try {
193
+ const data = await getInstance(instanceId);
194
+ setInstance(data);
195
+ setConfigText(JSON.stringify(data.config, null, 2));
196
+ if (data.budgetCents !== undefined) setBudgetCents(data.budgetCents);
197
+ } catch (err) {
198
+ setError(toUserMessage(err, "Failed to load instance"));
199
+ } finally {
200
+ setLoading(false);
201
+ }
202
+ }, [instanceId]);
203
+
204
+ const loadSnapshots = useCallback(async () => {
205
+ setSnapshotsLoading(true);
206
+ setSnapshotsError(null);
207
+ try {
208
+ const data = await listSnapshots(instanceId);
209
+ setSnapshots(data);
210
+ } catch (err) {
211
+ setSnapshotsError(toUserMessage(err, "Failed to load snapshots"));
212
+ } finally {
213
+ setSnapshotsLoading(false);
214
+ }
215
+ }, [instanceId]);
216
+
217
+ useEffect(() => {
218
+ load();
219
+ }, [load]);
220
+
221
+ useEffect(() => {
222
+ if (activeTab === "snapshots" && !snapshotsLoaded.current) {
223
+ snapshotsLoaded.current = true;
224
+ loadSnapshots();
225
+ }
226
+ }, [activeTab, loadSnapshots]);
227
+
228
+ const loadSecrets = useCallback(async () => {
229
+ setSecretsLoading(true);
230
+ setSecretsError(null);
231
+ try {
232
+ const keys = await getInstanceSecretKeys(instanceId);
233
+ setSecretKeys(keys);
234
+ setSecretValues({});
235
+ setNewSecretRows([]);
236
+ setSecretsStatus("idle");
237
+ } catch (err) {
238
+ setSecretKeys([]);
239
+ setSecretsStatus("error");
240
+ setSecretsError(toUserMessage(err, "Failed to load secrets"));
241
+ } finally {
242
+ setSecretsLoading(false);
243
+ }
244
+ }, [instanceId]);
245
+
246
+ useEffect(() => {
247
+ if (activeTab === "config" && !secretsLoaded.current) {
248
+ secretsLoaded.current = true;
249
+ loadSecrets();
250
+ }
251
+ }, [activeTab, loadSecrets]);
252
+
253
+ async function handleSaveSecrets() {
254
+ setSecretsError(null);
255
+ setSecretsStatus("idle");
256
+ const payload: Record<string, string> = {};
257
+ for (const key of secretKeys) {
258
+ const val = secretValues[key];
259
+ if (val?.trim()) {
260
+ payload[key] = val.trim();
261
+ }
262
+ }
263
+ // Check for new rows whose key duplicates an existing secret key.
264
+ const duplicates = newSecretRows
265
+ .map((row) => row.key.trim())
266
+ .filter((k) => k && secretKeys.includes(k));
267
+ if (duplicates.length > 0) {
268
+ setSecretsStatus("error");
269
+ setSecretsError(
270
+ `Duplicate secret key${duplicates.length > 1 ? "s" : ""}: ${duplicates.join(", ")}. Use the existing field above to update it.`,
271
+ );
272
+ return;
273
+ }
274
+ for (const row of newSecretRows) {
275
+ if (row.key.trim() && row.value.trim()) {
276
+ payload[row.key.trim()] = row.value.trim();
277
+ }
278
+ }
279
+ if (Object.keys(payload).length === 0) {
280
+ setSecretsStatus("error");
281
+ setSecretsError("No secret values to save");
282
+ return;
283
+ }
284
+ setSecretsSaving(true);
285
+ try {
286
+ await updateInstanceSecrets(instanceId, payload);
287
+ setSecretsStatus("saved");
288
+ setSecretValues({});
289
+ setNewSecretRows([]);
290
+ await loadSecrets();
291
+ } catch (err) {
292
+ setSecretsStatus("error");
293
+ setSecretsError(toUserMessage(err, "Failed to save secrets"));
294
+ } finally {
295
+ setSecretsSaving(false);
296
+ }
297
+ }
298
+
299
+ async function handleAction(action: "start" | "stop" | "restart" | "destroy") {
300
+ setActionError(null);
301
+ try {
302
+ await controlInstance(instanceId, action);
303
+ await load();
304
+ } catch (err) {
305
+ setActionError(toUserMessage(err, `Failed to ${action} instance`));
306
+ }
307
+ }
308
+
309
+ async function handleSaveBudget() {
310
+ setBudgetSaving(true);
311
+ setBudgetError(null);
312
+ try {
313
+ await updateInstanceBudget(instanceId, budgetCents);
314
+ await load();
315
+ } catch (err) {
316
+ setBudgetError(toUserMessage(err, "Failed to update budget"));
317
+ } finally {
318
+ setBudgetSaving(false);
319
+ }
320
+ }
321
+
322
+ async function handleCreateSnapshot() {
323
+ setSnapshotsError(null);
324
+ setCreating(true);
325
+ try {
326
+ await createSnapshot(instanceId);
327
+ await loadSnapshots();
328
+ } catch (err) {
329
+ setSnapshotsError(toUserMessage(err, "Failed to create snapshot"));
330
+ } finally {
331
+ setCreating(false);
332
+ }
333
+ }
334
+
335
+ async function handleRestore(snapshot: Snapshot) {
336
+ setRestoring(true);
337
+ try {
338
+ await restoreSnapshot(instanceId, snapshot.id);
339
+ setConfirmRestore(null);
340
+ await load();
341
+ await loadSnapshots();
342
+ } catch (err) {
343
+ setSnapshotsError(toUserMessage(err, "Failed to restore snapshot"));
344
+ setConfirmRestore(null);
345
+ } finally {
346
+ setRestoring(false);
347
+ }
348
+ }
349
+
350
+ async function handleDelete(snapshot: Snapshot) {
351
+ setSnapshotsError(null);
352
+ setDeleting(true);
353
+ try {
354
+ await deleteSnapshot(instanceId, snapshot.id);
355
+ setConfirmDelete(null);
356
+ await loadSnapshots();
357
+ } catch (err) {
358
+ setSnapshotsError(toUserMessage(err, "Failed to delete snapshot"));
359
+ setConfirmDelete(null);
360
+ } finally {
361
+ setDeleting(false);
362
+ }
363
+ }
364
+
365
+ if (loading) {
366
+ return (
367
+ <div className="space-y-6">
368
+ <div className="space-y-2">
369
+ <Skeleton className="h-5 w-24" />
370
+ <Skeleton className="h-7 w-48" />
371
+ <div className="flex items-center gap-3">
372
+ <Skeleton className="h-5 w-16" />
373
+ <Skeleton className="h-4 w-20" />
374
+ <Skeleton className="h-4 w-20" />
375
+ </div>
376
+ </div>
377
+ <Skeleton className="h-px w-full" />
378
+ <Skeleton className="h-9 w-96" />
379
+ <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
380
+ {Array.from({ length: 8 }, (_, n) => `sk-${n}`).map((skId) => (
381
+ <div key={skId} className="rounded-sm border p-4 space-y-2">
382
+ <Skeleton className="h-3 w-16" />
383
+ <Skeleton className="h-5 w-20" />
384
+ </div>
385
+ ))}
386
+ </div>
387
+ </div>
388
+ );
389
+ }
390
+
391
+ if (error || !instance) {
392
+ return (
393
+ <div className="flex h-[60vh] flex-col items-center justify-center gap-4">
394
+ <p className="text-muted-foreground">{error ?? "Instance not found"}</p>
395
+ <Button variant="outline" asChild>
396
+ <Link href="/instances">Back to Instances</Link>
397
+ </Button>
398
+ </div>
399
+ );
400
+ }
401
+
402
+ return (
403
+ <div className="space-y-6">
404
+ {/* Header */}
405
+ <div className="flex items-start justify-between">
406
+ <div className="space-y-1">
407
+ <div className="flex items-center gap-3">
408
+ <Button variant="ghost" size="sm" asChild>
409
+ <Link href="/instances" className="inline-flex items-center gap-1">
410
+ <ArrowLeft className="size-4" />
411
+ Instances
412
+ </Link>
413
+ </Button>
414
+ </div>
415
+ {renaming ? (
416
+ <div className="flex items-center gap-2">
417
+ <Input
418
+ autoFocus
419
+ value={renameValue}
420
+ onChange={(e) => setRenameValue(e.target.value)}
421
+ onKeyDown={(e) => {
422
+ if (e.key === "Enter") handleRename();
423
+ if (e.key === "Escape") setRenaming(false);
424
+ }}
425
+ className="h-9 w-64 text-lg font-bold"
426
+ disabled={renameSaving}
427
+ />
428
+ <Button
429
+ variant="ghost"
430
+ size="icon-xs"
431
+ onClick={handleRename}
432
+ disabled={renameSaving}
433
+ aria-label="Confirm rename"
434
+ >
435
+ {renameSaving ? (
436
+ <Loader2 className="size-4 animate-spin" />
437
+ ) : (
438
+ <Check className="size-4" />
439
+ )}
440
+ </Button>
441
+ <Button
442
+ variant="ghost"
443
+ size="icon-xs"
444
+ onClick={() => setRenaming(false)}
445
+ disabled={renameSaving}
446
+ aria-label="Cancel rename"
447
+ >
448
+ <X className="size-4" />
449
+ </Button>
450
+ </div>
451
+ ) : (
452
+ <h1 className="flex items-center gap-2 text-2xl font-bold tracking-tight">
453
+ {instance.name}
454
+ <span
455
+ className={cn("size-2 rounded-full", {
456
+ "bg-emerald-500 animate-[pulse-dot_2s_ease-in-out_infinite]":
457
+ instance.status === "running",
458
+ "bg-zinc-400": instance.status === "stopped",
459
+ "bg-yellow-500": instance.status === "degraded",
460
+ "bg-red-500 animate-[pulse-dot_0.8s_ease-in-out_infinite]":
461
+ instance.status === "error",
462
+ })}
463
+ />
464
+ <Button
465
+ variant="ghost"
466
+ size="icon-xs"
467
+ onClick={() => {
468
+ setRenameValue(instance.name);
469
+ setRenaming(true);
470
+ }}
471
+ aria-label="Rename"
472
+ >
473
+ <Pencil className="size-3.5 text-muted-foreground" />
474
+ </Button>
475
+ </h1>
476
+ )}
477
+ <div className="flex items-center gap-3 text-sm text-muted-foreground">
478
+ <StatusBadge status={instance.status} />
479
+ {imageStatusError && (
480
+ <span className="text-xs text-destructive">{imageStatusError}</span>
481
+ )}
482
+ {updateAvailable && (
483
+ <Badge
484
+ variant="outline"
485
+ className="gap-1.5 bg-amber-500/15 text-amber-500 border-amber-500/25"
486
+ >
487
+ <span
488
+ className={cn(
489
+ "size-1.5 rounded-full bg-amber-500",
490
+ pulling && "animate-[pulse-dot_0.8s_ease-in-out_infinite]",
491
+ )}
492
+ />
493
+ <span className="text-[11px] font-mono uppercase tracking-wider">
494
+ {pulling ? "Pulling..." : "Update available"}
495
+ </span>
496
+ </Badge>
497
+ )}
498
+ <span>{instance.provider}</span>
499
+ {instance.subdomain && (
500
+ <a
501
+ href={`https://${instance.subdomain}.${getBrandConfig().domain}`}
502
+ target="_blank"
503
+ rel="noopener noreferrer"
504
+ className="font-mono text-xs text-terminal hover:underline"
505
+ >
506
+ {instance.subdomain}.{getBrandConfig().domain}
507
+ </a>
508
+ )}
509
+ </div>
510
+ </div>
511
+ <div className="flex gap-2">
512
+ {updateAvailable && (
513
+ <Button
514
+ size="sm"
515
+ variant="outline"
516
+ className="border-amber-500/30 text-amber-500 hover:bg-amber-500/10 hover:text-amber-500 hover:border-amber-500/50 focus-visible:ring-amber-500/30 transition-colors duration-150"
517
+ onClick={() => setConfirmPull(true)}
518
+ disabled={pulling}
519
+ >
520
+ {pulling ? (
521
+ <Loader2 className="mr-1.5 size-4 animate-spin" />
522
+ ) : (
523
+ <ArrowDownToLine className="mr-1.5 size-4" />
524
+ )}
525
+ {pulling ? "Pulling..." : "Pull Update"}
526
+ </Button>
527
+ )}
528
+ {instance.status === "stopped" && (
529
+ <Button size="sm" variant="terminal" onClick={() => handleAction("start")}>
530
+ Start
531
+ </Button>
532
+ )}
533
+ {(instance.status === "running" || instance.status === "degraded") && (
534
+ <>
535
+ <Button size="sm" variant="outline" onClick={() => handleAction("stop")}>
536
+ Stop
537
+ </Button>
538
+ <Button size="sm" variant="outline" onClick={() => handleAction("restart")}>
539
+ Restart
540
+ </Button>
541
+ </>
542
+ )}
543
+ <Button size="sm" variant="destructive" onClick={() => setDestroyOpen(true)}>
544
+ Destroy
545
+ </Button>
546
+ </div>
547
+ </div>
548
+
549
+ {actionError && (
550
+ <div className="rounded-md border border-red-500/25 bg-red-500/10 px-4 py-3 text-sm text-red-500">
551
+ {actionError}
552
+ </div>
553
+ )}
554
+
555
+ <Separator />
556
+
557
+ {/* Tabs */}
558
+ <Tabs value={activeTab} onValueChange={setActiveTab}>
559
+ <TabsList className="bg-transparent border-b border-border rounded-none p-0 h-auto gap-0">
560
+ {[
561
+ "overview",
562
+ "health",
563
+ "metrics",
564
+ "logs",
565
+ "plugins",
566
+ "channels",
567
+ "friends",
568
+ "sessions",
569
+ "snapshots",
570
+ "config",
571
+ ].map((tab) => (
572
+ <TabsTrigger
573
+ key={tab}
574
+ value={tab}
575
+ className="rounded-none border-b-2 border-transparent px-4 py-2 text-sm capitalize text-muted-foreground data-[state=active]:border-b-terminal data-[state=active]:text-terminal data-[state=active]:shadow-none data-[state=active]:bg-transparent"
576
+ >
577
+ {tab.charAt(0).toUpperCase() + tab.slice(1)}
578
+ </TabsTrigger>
579
+ ))}
580
+ </TabsList>
581
+
582
+ {/* Overview Tab */}
583
+ <TabsContent value="overview" className="mt-4">
584
+ <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
585
+ <MetricCard title="Status" value={instance.status} status={instance.status} />
586
+ <MetricCard title="Uptime" value={formatUptime(instance.uptime)} />
587
+ <MetricCard
588
+ title="Memory"
589
+ value={`${instance.resourceUsage.memoryMb} MB`}
590
+ progress={instance.resourceUsage.memoryMb / 10.24}
591
+ />
592
+ <MetricCard
593
+ title="CPU"
594
+ value={`${instance.resourceUsage.cpuPercent}%`}
595
+ progress={instance.resourceUsage.cpuPercent}
596
+ />
597
+ <MetricCard title="Plugins" value={String(instance.plugins.length)} />
598
+ <MetricCard title="Channels" value={String(instance.channelDetails.length)} />
599
+ <MetricCard title="Active Sessions" value={String(instance.sessions.length)} />
600
+ <MetricCard title="Created" value={new Date(instance.createdAt).toLocaleDateString()} />
601
+ </div>
602
+
603
+ {/* Budget management — shown when backend provides budget data */}
604
+ {instance.budgetCents !== undefined && (
605
+ <Card className="mt-4">
606
+ <CardHeader className="pb-3">
607
+ <CardTitle className="text-sm font-medium">Spending Budget</CardTitle>
608
+ </CardHeader>
609
+ <CardContent className="space-y-4">
610
+ <div className="flex items-center justify-between">
611
+ <span className="text-2xl font-bold font-mono">
612
+ ${(budgetCents / 100).toFixed(2)}
613
+ </span>
614
+ <span className="text-xs text-muted-foreground">per month</span>
615
+ </div>
616
+ <input
617
+ type="range"
618
+ min={0}
619
+ max={10000}
620
+ step={100}
621
+ value={budgetCents}
622
+ onChange={(e) => setBudgetCents(Number(e.target.value))}
623
+ className="w-full accent-terminal"
624
+ aria-label="Budget slider"
625
+ />
626
+ <div className="flex items-center justify-between text-xs text-muted-foreground">
627
+ <span>$0</span>
628
+ <span>$100</span>
629
+ </div>
630
+ {budgetError && <p className="text-sm text-destructive">{budgetError}</p>}
631
+ <div className="flex justify-end">
632
+ <Button
633
+ size="sm"
634
+ variant="terminal"
635
+ onClick={handleSaveBudget}
636
+ disabled={budgetSaving || budgetCents === instance.budgetCents}
637
+ >
638
+ {budgetSaving ? "Saving..." : "Update Budget"}
639
+ </Button>
640
+ </div>
641
+ </CardContent>
642
+ </Card>
643
+ )}
644
+ </TabsContent>
645
+
646
+ {/* Health Tab */}
647
+ <TabsContent value="health" className="mt-4">
648
+ <HealthOverview instanceId={instanceId} />
649
+ </TabsContent>
650
+
651
+ {/* Metrics Tab */}
652
+ <TabsContent value="metrics" className="mt-4">
653
+ <MetricsDashboard instanceId={instanceId} />
654
+ </TabsContent>
655
+
656
+ {/* Logs Tab (enhanced) */}
657
+ <TabsContent value="logs" className="mt-4">
658
+ <LogsViewer instanceId={instanceId} />
659
+ </TabsContent>
660
+
661
+ {/* Plugins Tab */}
662
+ <TabsContent value="plugins" className="mt-4">
663
+ {instance.plugins.length === 0 ? (
664
+ <p className="text-muted-foreground">No plugins installed.</p>
665
+ ) : (
666
+ <div className="rounded-md border">
667
+ <Table>
668
+ <TableHeader>
669
+ <TableRow>
670
+ <TableHead>Plugin</TableHead>
671
+ <TableHead>Version</TableHead>
672
+ <TableHead className="w-[100px]">Enabled</TableHead>
673
+ </TableRow>
674
+ </TableHeader>
675
+ <TableBody>
676
+ {instance.plugins.map((plugin) => (
677
+ <TableRow
678
+ key={plugin.id}
679
+ className="transition-colors hover:bg-muted/50 even:bg-muted/20"
680
+ >
681
+ <TableCell className="font-medium">{plugin.name}</TableCell>
682
+ <TableCell className="text-muted-foreground">{plugin.version}</TableCell>
683
+ <TableCell>
684
+ <span
685
+ className={
686
+ togglingPlugin === plugin.id
687
+ ? "opacity-70 cursor-wait transition-opacity duration-150"
688
+ : "transition-opacity duration-150"
689
+ }
690
+ >
691
+ <Switch
692
+ checked={plugin.enabled}
693
+ disabled={togglingPlugin !== null}
694
+ onCheckedChange={(checked) => handleTogglePlugin(plugin.id, checked)}
695
+ className="data-[state=checked]:bg-emerald-500 dark:data-[state=checked]:bg-emerald-500"
696
+ aria-label={`Toggle ${plugin.name}`}
697
+ />
698
+ </span>
699
+ </TableCell>
700
+ </TableRow>
701
+ ))}
702
+ </TableBody>
703
+ </Table>
704
+ </div>
705
+ )}
706
+ </TabsContent>
707
+
708
+ {/* Channels Tab */}
709
+ <TabsContent value="channels" className="mt-4">
710
+ {instance.channelDetails.length === 0 ? (
711
+ <p className="text-muted-foreground">No channels connected.</p>
712
+ ) : (
713
+ <div className="rounded-md border">
714
+ <Table>
715
+ <TableHeader>
716
+ <TableRow>
717
+ <TableHead>Channel</TableHead>
718
+ <TableHead>Type</TableHead>
719
+ <TableHead>Status</TableHead>
720
+ </TableRow>
721
+ </TableHeader>
722
+ <TableBody>
723
+ {instance.channelDetails.map((ch) => (
724
+ <TableRow
725
+ key={ch.id}
726
+ className="transition-colors hover:bg-muted/50 even:bg-muted/20"
727
+ >
728
+ <TableCell className="font-medium">{ch.name}</TableCell>
729
+ <TableCell className="text-muted-foreground">{ch.type}</TableCell>
730
+ <TableCell>
731
+ <ChannelStatusBadge status={ch.status} />
732
+ </TableCell>
733
+ </TableRow>
734
+ ))}
735
+ </TableBody>
736
+ </Table>
737
+ </div>
738
+ )}
739
+ </TabsContent>
740
+
741
+ {/* Friends Tab */}
742
+ <TabsContent value="friends" className="mt-4">
743
+ <FriendsTab instanceId={instanceId} />
744
+ </TabsContent>
745
+
746
+ {/* Sessions Tab */}
747
+ <TabsContent value="sessions" className="mt-4">
748
+ {instance.sessions.length === 0 ? (
749
+ <p className="text-muted-foreground">No active sessions.</p>
750
+ ) : (
751
+ <div className="rounded-md border">
752
+ <Table>
753
+ <TableHeader>
754
+ <TableRow>
755
+ <TableHead>Session ID</TableHead>
756
+ <TableHead>User</TableHead>
757
+ <TableHead>Messages</TableHead>
758
+ <TableHead>Started</TableHead>
759
+ <TableHead>Last Activity</TableHead>
760
+ </TableRow>
761
+ </TableHeader>
762
+ <TableBody>
763
+ {instance.sessions.map((sess) => (
764
+ <TableRow
765
+ key={sess.id}
766
+ className="transition-colors hover:bg-muted/50 even:bg-muted/20"
767
+ >
768
+ <TableCell className="font-mono text-sm">{sess.id}</TableCell>
769
+ <TableCell>{sess.userId}</TableCell>
770
+ <TableCell>{sess.messageCount}</TableCell>
771
+ <TableCell className="text-muted-foreground">
772
+ {new Date(sess.startedAt).toLocaleTimeString()}
773
+ </TableCell>
774
+ <TableCell className="text-muted-foreground">
775
+ {new Date(sess.lastActivityAt).toLocaleTimeString()}
776
+ </TableCell>
777
+ </TableRow>
778
+ ))}
779
+ </TableBody>
780
+ </Table>
781
+ </div>
782
+ )}
783
+ </TabsContent>
784
+
785
+ {/* Snapshots Tab */}
786
+ <TabsContent value="snapshots" className="mt-4 space-y-4">
787
+ <div className="flex items-center justify-between">
788
+ <h3 className="text-sm font-medium text-muted-foreground">
789
+ {snapshots.length} snapshot{snapshots.length !== 1 ? "s" : ""}
790
+ </h3>
791
+ <Button size="sm" onClick={handleCreateSnapshot} disabled={creating}>
792
+ {creating ? "Creating..." : "Create Snapshot"}
793
+ </Button>
794
+ </div>
795
+
796
+ {snapshotsError && (
797
+ <div className="rounded-md border border-red-500/25 bg-red-500/10 px-4 py-3 text-sm text-red-500">
798
+ {snapshotsError}
799
+ </div>
800
+ )}
801
+
802
+ {snapshotsLoading ? (
803
+ <div className="space-y-2">
804
+ {Array.from({ length: 3 }, (_, i) => `snap-sk-${i}`).map((skId) => (
805
+ <Skeleton key={skId} className="h-12 w-full" />
806
+ ))}
807
+ </div>
808
+ ) : snapshots.length === 0 ? (
809
+ <p className="text-muted-foreground">No snapshots yet.</p>
810
+ ) : (
811
+ <div className="rounded-md border">
812
+ <Table>
813
+ <TableHeader>
814
+ <TableRow>
815
+ <TableHead>Name</TableHead>
816
+ <TableHead>Type</TableHead>
817
+ <TableHead>Trigger</TableHead>
818
+ <TableHead>Size</TableHead>
819
+ <TableHead>Created</TableHead>
820
+ <TableHead className="text-right">Actions</TableHead>
821
+ </TableRow>
822
+ </TableHeader>
823
+ <TableBody>
824
+ {snapshots.map((snap) => (
825
+ <TableRow
826
+ key={snap.id}
827
+ className="transition-colors hover:bg-muted/50 even:bg-muted/20"
828
+ >
829
+ <TableCell className="font-medium">
830
+ {snap.name ?? <span className="text-muted-foreground italic">unnamed</span>}
831
+ </TableCell>
832
+ <TableCell>
833
+ <Badge variant="outline">{snap.type}</Badge>
834
+ </TableCell>
835
+ <TableCell className="text-muted-foreground">{snap.trigger}</TableCell>
836
+ <TableCell className="text-muted-foreground">{snap.sizeMb} MB</TableCell>
837
+ <TableCell className="text-muted-foreground">
838
+ {new Date(snap.createdAt).toLocaleString()}
839
+ </TableCell>
840
+ <TableCell className="text-right">
841
+ <div className="flex justify-end gap-1">
842
+ <Button
843
+ size="sm"
844
+ variant="outline"
845
+ onClick={() => setConfirmRestore(snap)}
846
+ >
847
+ Restore
848
+ </Button>
849
+ <Button
850
+ size="sm"
851
+ variant="destructive"
852
+ onClick={() => setConfirmDelete(snap)}
853
+ >
854
+ Delete
855
+ </Button>
856
+ </div>
857
+ </TableCell>
858
+ </TableRow>
859
+ ))}
860
+ </TableBody>
861
+ </Table>
862
+ </div>
863
+ )}
864
+
865
+ {/* Restore confirmation dialog */}
866
+ <Dialog open={!!confirmRestore} onOpenChange={(open) => !open && setConfirmRestore(null)}>
867
+ <DialogContent>
868
+ <DialogHeader>
869
+ <DialogTitle>Restore Snapshot</DialogTitle>
870
+ <DialogDescription>This will restart your bot from this snapshot</DialogDescription>
871
+ </DialogHeader>
872
+ <p className="text-sm text-muted-foreground">
873
+ Restoring from snapshot <span className="font-mono">{confirmRestore?.id}</span>
874
+ {confirmRestore?.name ? ` (${confirmRestore.name})` : ""} created on{" "}
875
+ {confirmRestore ? new Date(confirmRestore.createdAt).toLocaleString() : ""}.
876
+ </p>
877
+ <DialogFooter>
878
+ <Button
879
+ variant="outline"
880
+ onClick={() => setConfirmRestore(null)}
881
+ disabled={restoring}
882
+ >
883
+ Cancel
884
+ </Button>
885
+ <Button
886
+ variant="terminal"
887
+ onClick={() => confirmRestore && handleRestore(confirmRestore)}
888
+ disabled={restoring}
889
+ >
890
+ {restoring ? "Restoring..." : "Confirm Restore"}
891
+ </Button>
892
+ </DialogFooter>
893
+ </DialogContent>
894
+ </Dialog>
895
+
896
+ {/* Delete confirmation dialog */}
897
+ <Dialog open={!!confirmDelete} onOpenChange={(open) => !open && setConfirmDelete(null)}>
898
+ <DialogContent>
899
+ <DialogHeader>
900
+ <DialogTitle>Delete Snapshot</DialogTitle>
901
+ <DialogDescription>
902
+ This will permanently delete this snapshot. This action cannot be undone.
903
+ </DialogDescription>
904
+ </DialogHeader>
905
+ <DialogFooter>
906
+ <Button
907
+ variant="outline"
908
+ onClick={() => setConfirmDelete(null)}
909
+ disabled={deleting}
910
+ >
911
+ Cancel
912
+ </Button>
913
+ <Button
914
+ variant="destructive"
915
+ onClick={() => confirmDelete && handleDelete(confirmDelete)}
916
+ disabled={deleting}
917
+ >
918
+ {deleting ? "Deleting..." : "Delete Snapshot"}
919
+ </Button>
920
+ </DialogFooter>
921
+ </DialogContent>
922
+ </Dialog>
923
+ </TabsContent>
924
+
925
+ {/* Config Tab */}
926
+ <TabsContent value="config" className="mt-4 space-y-4">
927
+ <div className="space-y-2">
928
+ <label htmlFor="config-editor" className="text-sm font-medium">
929
+ Instance Configuration (JSON)
930
+ </label>
931
+ <div
932
+ className={cn(
933
+ "relative rounded-md border bg-black/80 overflow-hidden",
934
+ configStatus === "invalid" ? "border-red-500" : "border-border",
935
+ )}
936
+ >
937
+ <div className="flex items-center gap-2 border-b border-border/50 px-3 py-1.5 text-xs text-muted-foreground">
938
+ <span className="inline-block h-2 w-2 rounded-full bg-terminal" />
939
+ <span>CONFIG EDITOR</span>
940
+ </div>
941
+ <Textarea
942
+ id="config-editor"
943
+ className="min-h-[300px] font-mono text-sm bg-transparent border-0 rounded-none focus-visible:ring-0 text-terminal/90 resize-y"
944
+ value={configText}
945
+ onChange={(e) => setConfigText(e.target.value)}
946
+ spellCheck={false}
947
+ />
948
+ </div>
949
+ </div>
950
+ <div className="flex items-center justify-end gap-3">
951
+ {saving && <span className="text-sm text-muted-foreground">Saving...</span>}
952
+ {configStatus === "saved" && !saving && (
953
+ <span className="text-sm text-emerald-500">Config saved</span>
954
+ )}
955
+ {configStatus === "invalid" && (
956
+ <span className="text-sm text-red-500">Invalid JSON</span>
957
+ )}
958
+ {configStatus === "error" && configError && (
959
+ <span className="text-sm text-red-500">{configError}</span>
960
+ )}
961
+ <Button
962
+ disabled={saving || configStatus === "invalid"}
963
+ onClick={async () => {
964
+ setConfigError(null);
965
+ let parsed: unknown;
966
+ try {
967
+ parsed = JSON.parse(configText);
968
+ } catch {
969
+ setConfigStatus("invalid");
970
+ return;
971
+ }
972
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
973
+ setConfigStatus("invalid");
974
+ return;
975
+ }
976
+ setConfigStatus("idle");
977
+ setSaving(true);
978
+ try {
979
+ const env: Record<string, string> = {};
980
+ for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
981
+ if (
982
+ v === null ||
983
+ (typeof v !== "string" && typeof v !== "number" && typeof v !== "boolean")
984
+ ) {
985
+ throw new Error(`Value for key "${k}" must be a string, number, or boolean`);
986
+ }
987
+ env[k] = String(v);
988
+ }
989
+ await updateInstanceConfig(instanceId, env);
990
+ setConfigStatus("saved");
991
+ } catch (err) {
992
+ setConfigStatus("error");
993
+ setConfigError(toUserMessage(err, "Failed to save config"));
994
+ } finally {
995
+ setSaving(false);
996
+ }
997
+ }}
998
+ >
999
+ {saving ? "Saving..." : "Save Config"}
1000
+ </Button>
1001
+ </div>
1002
+
1003
+ <Separator className="my-6" />
1004
+
1005
+ <div className="space-y-4">
1006
+ <div className="flex items-center justify-between">
1007
+ <div>
1008
+ <h3 className="text-sm font-medium">Secrets</h3>
1009
+ <p className="text-xs text-muted-foreground">
1010
+ Write-only. Stored values are never displayed.
1011
+ </p>
1012
+ </div>
1013
+ <Button
1014
+ size="sm"
1015
+ variant="outline"
1016
+ onClick={() => {
1017
+ const id = nextSecretId.current++;
1018
+ setNewSecretRows((prev) => [...prev, { id, key: "", value: "" }]);
1019
+ }}
1020
+ >
1021
+ <Plus className="mr-1.5 size-3.5" />
1022
+ Add Secret
1023
+ </Button>
1024
+ </div>
1025
+
1026
+ {secretsLoading ? (
1027
+ <div className="space-y-2">
1028
+ <Skeleton className="h-10 w-full" />
1029
+ <Skeleton className="h-10 w-full" />
1030
+ </div>
1031
+ ) : secretKeys.length === 0 && newSecretRows.length === 0 ? (
1032
+ <p className="text-sm text-muted-foreground">No secrets configured.</p>
1033
+ ) : (
1034
+ <div className="rounded-md border border-border bg-black/80 overflow-hidden">
1035
+ <div className="flex items-center gap-2 border-b border-border/50 px-3 py-1.5 text-xs text-muted-foreground">
1036
+ <Lock className="size-3 text-terminal" />
1037
+ <span>SECRETS VAULT</span>
1038
+ </div>
1039
+ <Table>
1040
+ <TableHeader>
1041
+ <TableRow>
1042
+ <TableHead>Key</TableHead>
1043
+ <TableHead>Value</TableHead>
1044
+ <TableHead className="w-[60px]" />
1045
+ </TableRow>
1046
+ </TableHeader>
1047
+ <TableBody>
1048
+ {secretKeys.map((key) => (
1049
+ <TableRow key={key} className="hover:bg-muted/50">
1050
+ <TableCell className="font-mono text-sm">
1051
+ {key}
1052
+ <Badge
1053
+ variant="outline"
1054
+ className="ml-2 text-[10px] border-terminal/30 text-terminal/70 bg-terminal/5"
1055
+ >
1056
+ SET
1057
+ </Badge>
1058
+ </TableCell>
1059
+ <TableCell>
1060
+ <Input
1061
+ type="password"
1062
+ placeholder="Enter new value..."
1063
+ value={secretValues[key] ?? ""}
1064
+ onChange={(e) =>
1065
+ setSecretValues((prev) => ({ ...prev, [key]: e.target.value }))
1066
+ }
1067
+ className="font-mono text-sm bg-transparent border-border focus:ring-terminal/50 focus:border-terminal/50"
1068
+ />
1069
+ </TableCell>
1070
+ <TableCell />
1071
+ </TableRow>
1072
+ ))}
1073
+ {newSecretRows.map((row) => (
1074
+ <TableRow key={`new-${row.id}`} className="hover:bg-muted/50">
1075
+ <TableCell>
1076
+ <Input
1077
+ placeholder="SECRET_KEY_NAME"
1078
+ value={row.key}
1079
+ onChange={(e) =>
1080
+ setNewSecretRows((prev) =>
1081
+ prev.map((r) =>
1082
+ r.id === row.id ? { ...r, key: e.target.value } : r,
1083
+ ),
1084
+ )
1085
+ }
1086
+ className="font-mono text-sm bg-transparent"
1087
+ />
1088
+ </TableCell>
1089
+ <TableCell>
1090
+ <Input
1091
+ type="password"
1092
+ placeholder="Enter value..."
1093
+ value={row.value}
1094
+ onChange={(e) =>
1095
+ setNewSecretRows((prev) =>
1096
+ prev.map((r) =>
1097
+ r.id === row.id ? { ...r, value: e.target.value } : r,
1098
+ ),
1099
+ )
1100
+ }
1101
+ className="font-mono text-sm bg-transparent"
1102
+ />
1103
+ </TableCell>
1104
+ <TableCell>
1105
+ <Button
1106
+ size="sm"
1107
+ variant="ghost"
1108
+ onClick={() =>
1109
+ setNewSecretRows((prev) => prev.filter((r) => r.id !== row.id))
1110
+ }
1111
+ >
1112
+ <Trash2 className="size-3.5 text-muted-foreground hover:text-destructive" />
1113
+ </Button>
1114
+ </TableCell>
1115
+ </TableRow>
1116
+ ))}
1117
+ </TableBody>
1118
+ </Table>
1119
+ </div>
1120
+ )}
1121
+
1122
+ <div className="flex items-center justify-end gap-3">
1123
+ {secretsSaving && <span className="text-sm text-muted-foreground">Saving...</span>}
1124
+ {secretsStatus === "saved" && !secretsSaving && (
1125
+ <span className="text-sm text-emerald-500">Secrets saved</span>
1126
+ )}
1127
+ {secretsStatus === "error" && secretsError && (
1128
+ <span className="text-sm text-red-500">{secretsError}</span>
1129
+ )}
1130
+ <Button disabled={secretsSaving} onClick={handleSaveSecrets}>
1131
+ {secretsSaving ? "Saving..." : "Save Secrets"}
1132
+ </Button>
1133
+ </div>
1134
+ </div>
1135
+ </TabsContent>
1136
+ </Tabs>
1137
+
1138
+ {/* Destroy confirmation dialog */}
1139
+ <Dialog
1140
+ open={destroyOpen}
1141
+ onOpenChange={(open) => {
1142
+ if (!open) {
1143
+ setDestroyOpen(false);
1144
+ setDestroyConfirmText("");
1145
+ setActionError(null);
1146
+ }
1147
+ }}
1148
+ >
1149
+ <DialogContent>
1150
+ <DialogHeader>
1151
+ <DialogTitle>Destroy {instance.name} permanently?</DialogTitle>
1152
+ <DialogDescription>
1153
+ This action is permanent and cannot be undone. The instance and all its data will be
1154
+ destroyed. Type <strong className="text-foreground">{instance.name}</strong> to
1155
+ confirm.
1156
+ </DialogDescription>
1157
+ </DialogHeader>
1158
+
1159
+ {actionError && <p className="text-sm text-destructive">{actionError}</p>}
1160
+
1161
+ <Input
1162
+ autoFocus
1163
+ placeholder={`Type "${instance.name}" to confirm`}
1164
+ value={destroyConfirmText}
1165
+ onChange={(e) => setDestroyConfirmText(e.target.value)}
1166
+ />
1167
+
1168
+ <DialogFooter>
1169
+ <Button
1170
+ variant="outline"
1171
+ onClick={() => {
1172
+ setDestroyOpen(false);
1173
+ setDestroyConfirmText("");
1174
+ setActionError(null);
1175
+ }}
1176
+ >
1177
+ Cancel
1178
+ </Button>
1179
+ <Button
1180
+ variant="destructive"
1181
+ disabled={destroying || destroyConfirmText !== instance.name}
1182
+ onClick={async () => {
1183
+ setDestroying(true);
1184
+ setActionError(null);
1185
+ try {
1186
+ await controlInstance(instanceId, "destroy");
1187
+ setDestroyOpen(false);
1188
+ router.push("/instances");
1189
+ } catch (err) {
1190
+ setActionError(toUserMessage(err, "Failed to destroy instance"));
1191
+ } finally {
1192
+ setDestroying(false);
1193
+ }
1194
+ }}
1195
+ >
1196
+ {destroying ? "Destroying..." : "Destroy permanently"}
1197
+ </Button>
1198
+ </DialogFooter>
1199
+ </DialogContent>
1200
+ </Dialog>
1201
+
1202
+ <AlertDialog open={confirmPull} onOpenChange={setConfirmPull}>
1203
+ <AlertDialogContent>
1204
+ <AlertDialogHeader>
1205
+ <AlertDialogTitle>Pull Update</AlertDialogTitle>
1206
+ <AlertDialogDescription>
1207
+ This will pull the latest image and restart the bot. Continue?
1208
+ </AlertDialogDescription>
1209
+ </AlertDialogHeader>
1210
+ <AlertDialogFooter>
1211
+ <AlertDialogCancel>Cancel</AlertDialogCancel>
1212
+ <AlertDialogAction disabled={pulling} onClick={handlePullUpdate}>
1213
+ Continue
1214
+ </AlertDialogAction>
1215
+ </AlertDialogFooter>
1216
+ </AlertDialogContent>
1217
+ </AlertDialog>
1218
+ </div>
1219
+ );
1220
+ }
1221
+
1222
+ function MetricCard({
1223
+ title,
1224
+ value,
1225
+ status,
1226
+ progress,
1227
+ }: {
1228
+ title: string;
1229
+ value: string;
1230
+ status?: InstanceStatus;
1231
+ progress?: number;
1232
+ }) {
1233
+ return (
1234
+ <Card className="py-4">
1235
+ <CardHeader className="pb-1">
1236
+ <CardTitle className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
1237
+ {title}
1238
+ </CardTitle>
1239
+ </CardHeader>
1240
+ <CardContent className="space-y-2">
1241
+ <div className="flex items-center gap-2">
1242
+ {status && (
1243
+ <span
1244
+ className={cn("size-2 rounded-full", {
1245
+ "bg-emerald-500 animate-[pulse-dot_2s_ease-in-out_infinite]": status === "running",
1246
+ "bg-zinc-400": status === "stopped",
1247
+ "bg-yellow-500": status === "degraded",
1248
+ "bg-red-500 animate-[pulse-dot_0.8s_ease-in-out_infinite]": status === "error",
1249
+ })}
1250
+ />
1251
+ )}
1252
+ <p className="text-2xl font-bold tracking-tight">{value}</p>
1253
+ </div>
1254
+ {progress !== undefined && (
1255
+ <div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
1256
+ <div
1257
+ className={cn("h-full rounded-full transition-all", {
1258
+ "bg-terminal": progress < 70,
1259
+ "bg-yellow-500": progress >= 70 && progress < 90,
1260
+ "bg-red-500": progress >= 90,
1261
+ })}
1262
+ style={{ width: `${Math.min(progress, 100)}%` }}
1263
+ />
1264
+ </div>
1265
+ )}
1266
+ </CardContent>
1267
+ </Card>
1268
+ );
1269
+ }
1270
+
1271
+ function ChannelStatusBadge({ status }: { status: "connected" | "disconnected" | "error" }) {
1272
+ const config = {
1273
+ connected: {
1274
+ label: "Connected",
1275
+ className: "bg-emerald-500/15 text-emerald-500 border-emerald-500/25",
1276
+ },
1277
+ disconnected: {
1278
+ label: "Disconnected",
1279
+ className: "bg-zinc-500/15 text-zinc-400 border-zinc-500/25",
1280
+ },
1281
+ error: { label: "Error", className: "bg-red-500/15 text-red-500 border-red-500/25" },
1282
+ };
1283
+ const c = config[status];
1284
+ return (
1285
+ <Badge variant="outline" className={c.className}>
1286
+ {c.label}
1287
+ </Badge>
1288
+ );
1289
+ }
1290
+
1291
+ function formatUptime(seconds: number | null): string {
1292
+ if (seconds === null) return "--";
1293
+ const d = Math.floor(seconds / 86400);
1294
+ const h = Math.floor((seconds % 86400) / 3600);
1295
+ if (d > 0) return `${d}d ${h}h`;
1296
+ const m = Math.floor((seconds % 3600) / 60);
1297
+ return h > 0 ? `${h}h ${m}m` : `${m}m`;
1298
+ }