@hienlh/ppm 0.8.85 → 0.8.86
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/.claude.bak/agent-memory/tester/MEMORY.md +3 -0
- package/.claude.bak/agent-memory/tester/project-ppm-test-conventions.md +32 -0
- package/CHANGELOG.md +5 -187
- package/bun.lock +0 -5
- package/dist/web/assets/{_basePickBy-5PGDJbfF.js → _basePickBy-5eBmZ_lt.js} +1 -1
- package/dist/web/assets/{_baseUniq-BT4Ow4Kk.js → _baseUniq-DimLlN0y.js} +1 -1
- package/dist/web/assets/api-settings-CFw-lh5k.js +1 -0
- package/dist/web/assets/{arc-BAOivWpI.js → arc-D4SasZrA.js} +1 -1
- package/dist/web/assets/architecture-PBZL5I3N-CJupe6q_.js +1 -0
- package/dist/web/assets/{architectureDiagram-2XIMDMQ5-DWBCPMLF.js → architectureDiagram-2XIMDMQ5-nv0WbM7d.js} +1 -1
- package/dist/web/assets/{blockDiagram-WCTKOSBZ-TEF8Ally.js → blockDiagram-WCTKOSBZ-C1XvYrb8.js} +1 -1
- package/dist/web/assets/browser-tab-CmsL5eny.js +1 -0
- package/dist/web/assets/{c4Diagram-IC4MRINW-dV22iAsY.js → c4Diagram-IC4MRINW-CygDrbWJ.js} +1 -1
- package/dist/web/assets/channel-DmKoFTd_.js +1 -0
- package/dist/web/assets/chat-tab-CFWsf13Z.js +7 -0
- package/dist/web/assets/{chunk-4BX2VUAB-D4tOov49.js → chunk-4BX2VUAB-C2FDgsgT.js} +1 -1
- package/dist/web/assets/{chunk-55IACEB6-DJ6BynZ4.js → chunk-55IACEB6-jF4w6cat.js} +1 -1
- package/dist/web/assets/{chunk-7E7YKBS2-CiyUJxNI.js → chunk-7E7YKBS2-BVCECZFi.js} +1 -1
- package/dist/web/assets/{chunk-7R4GIKGN-BbIFzsIv.js → chunk-7R4GIKGN-DXTbeu5d.js} +2 -2
- package/dist/web/assets/{chunk-C72U2L5F-D21mS_6G.js → chunk-C72U2L5F-BaZqOsTs.js} +1 -1
- package/dist/web/assets/{chunk-EGIJ26TM-DzqmU2Z7.js → chunk-EGIJ26TM-Bky2tcH7.js} +1 -1
- package/dist/web/assets/{chunk-FMBD7UC4-DXncblvW.js → chunk-FMBD7UC4-Cp4BK9A8.js} +1 -1
- package/dist/web/assets/{chunk-GEFDOKGD-BbQkJu8C.js → chunk-GEFDOKGD-BosFEH7G.js} +1 -1
- package/dist/web/assets/chunk-GLR3WWYH-BnP-hOp6.js +2 -0
- package/dist/web/assets/chunk-HHEYEP7N-DKDPTPEZ.js +1 -0
- package/dist/web/assets/{chunk-JSJVCQXG-23tyvw8k.js → chunk-JSJVCQXG-H5Gbjsbr.js} +1 -1
- package/dist/web/assets/{chunk-KX2RTZJC-sQ0o-39C.js → chunk-KX2RTZJC-CWerSUwS.js} +1 -1
- package/dist/web/assets/{chunk-KYZI473N-BcUZNnwd.js → chunk-KYZI473N-FvwP7jUy.js} +1 -1
- package/dist/web/assets/{chunk-L3YUKLVL-C7qGJrfV.js → chunk-L3YUKLVL-D1PI_ORP.js} +1 -1
- package/dist/web/assets/{chunk-MX3YWQON-BpS_PtKp.js → chunk-MX3YWQON-C7Vzk_AI.js} +1 -1
- package/dist/web/assets/{chunk-NQ4KR5QH-wMgTlP7f.js → chunk-NQ4KR5QH-BceYBGYX.js} +1 -1
- package/dist/web/assets/{chunk-O4XLMI2P-JC6EGoUz.js → chunk-O4XLMI2P-WPtzgxql.js} +1 -1
- package/dist/web/assets/{chunk-OZEHJAEY-BXhYx3nO.js → chunk-OZEHJAEY-DlHXDeLY.js} +1 -1
- package/dist/web/assets/{chunk-PQ6SQG4A-D6BTbCQw.js → chunk-PQ6SQG4A-Ci_Prygb.js} +1 -1
- package/dist/web/assets/{chunk-PU5JKC2W-Dw8ClWch.js → chunk-PU5JKC2W-CO0zMN-z.js} +1 -1
- package/dist/web/assets/chunk-QZHKN3VN-C_wpI9wz.js +1 -0
- package/dist/web/assets/{chunk-R5LLSJPH-CFwSJijQ.js → chunk-R5LLSJPH-IAEEzfpM.js} +1 -1
- package/dist/web/assets/{chunk-WL4C6EOR-DfofndiH.js → chunk-WL4C6EOR-BLXalOgc.js} +1 -1
- package/dist/web/assets/{chunk-XIRO2GV7-Djlmrely.js → chunk-XIRO2GV7-Dx1Ri_p2.js} +1 -1
- package/dist/web/assets/{chunk-XPW4576I-BPQQBakK.js → chunk-XPW4576I-m9pPGKn7.js} +1 -1
- package/dist/web/assets/{chunk-XZSTWKYB-DxAOx4hG.js → chunk-XZSTWKYB-B_08ExbI.js} +1 -1
- package/dist/web/assets/{chunk-YBOYWFTD-CeU4Q-xC.js → chunk-YBOYWFTD-DqSOVcYe.js} +1 -1
- package/dist/web/assets/classDiagram-VBA2DB6C-B1T5uY-F.js +1 -0
- package/dist/web/assets/classDiagram-v2-RAHNMMFH-xs5vI3xC.js +1 -0
- package/dist/web/assets/clone-CijCFRT5.js +1 -0
- package/dist/web/assets/code-editor-H_dAh_fJ.js +1 -0
- package/dist/web/assets/{cose-bilkent-S5V4N54A-B_AWZsOP.js → cose-bilkent-S5V4N54A-DlL82QHu.js} +1 -1
- package/dist/web/assets/{dagre-Dbb5k38K.js → dagre-BmVoh2At.js} +1 -1
- package/dist/web/assets/{dagre-KLK3FWXG-BH7aWGRP.js → dagre-KLK3FWXG-sDrRW9MQ.js} +1 -1
- package/dist/web/assets/database-viewer-DBzsgEJ8.js +1 -0
- package/dist/web/assets/{diagram-E7M64L7V-B1Qz70Do.js → diagram-E7M64L7V-ChnAhgni.js} +1 -1
- package/dist/web/assets/{diagram-IFDJBPK2-k55eVqVU.js → diagram-IFDJBPK2-DW1J1uJd.js} +1 -1
- package/dist/web/assets/{diagram-P4PSJMXO-BkfNRc9U.js → diagram-P4PSJMXO-CQ32hyG_.js} +1 -1
- package/dist/web/assets/diff-viewer-DzS-OnAR.js +4 -0
- package/dist/web/assets/dist-0Va_2L7G.js +16 -0
- package/dist/web/assets/dist-D9irYETY.js +41 -0
- package/dist/web/assets/{erDiagram-INFDFZHY-CKzVujYI.js → erDiagram-INFDFZHY-6CHo6nOw.js} +1 -1
- package/dist/web/assets/{flowDiagram-PKNHOUZH-DIqcTrDV.js → flowDiagram-PKNHOUZH-DroDiNT0.js} +1 -1
- package/dist/web/assets/{ganttDiagram-A5KZAMGK-D4v7ZbVE.js → ganttDiagram-A5KZAMGK-DP0QBh8w.js} +1 -1
- package/dist/web/assets/git-graph-D3C7F8o3.js +1 -0
- package/dist/web/assets/gitGraph-HDMCJU4V-B0KvGQG8.js +1 -0
- package/dist/web/assets/{gitGraphDiagram-K3NZZRJ6-BTXo57mF.js → gitGraphDiagram-K3NZZRJ6-DvU3JGZn.js} +1 -1
- package/dist/web/assets/{graphlib-BcsNnGcW.js → graphlib-CQBb2thr.js} +1 -1
- package/dist/web/assets/index-CIkjfera.js +31 -0
- package/dist/web/assets/index-WKLuYsBY.css +2 -0
- package/dist/web/assets/info-3K5VOQVL-1uJ6_hCm.js +1 -0
- package/dist/web/assets/infoDiagram-LFFYTUFH-DLA5Q-3y.js +2 -0
- package/dist/web/assets/input-CGp1nFIg.js +1 -0
- package/dist/web/assets/{isEmpty-bnrF3Qbc.js → isEmpty-B4kqZBtn.js} +1 -1
- package/dist/web/assets/{ishikawaDiagram-PHBUUO56-BOyvKMmB.js → ishikawaDiagram-PHBUUO56-46yibrV5.js} +1 -1
- package/dist/web/assets/{journeyDiagram-4ABVD52K-ufoasAy6.js → journeyDiagram-4ABVD52K-BcmRwjK-.js} +1 -1
- package/dist/web/assets/{kanban-definition-K7BYSVSG-Bi0UTUeN.js → kanban-definition-K7BYSVSG-B619K53y.js} +1 -1
- package/dist/web/assets/keybindings-store-BdaoLwSo.js +1 -0
- package/dist/web/assets/{line-B78g-52T.js → line-1gcO63_w.js} +1 -1
- package/dist/web/assets/{linear-DP4mkX3m.js → linear-DfRqDoVd.js} +1 -1
- package/dist/web/assets/markdown-renderer-DH49Zag7.js +69 -0
- package/dist/web/assets/{mermaid-parser.core-DMIWdgEW.js → mermaid-parser.core-XtjZQOeM.js} +2 -2
- package/dist/web/assets/{mindmap-definition-YRQLILUH-BsfWvIoO.js → mindmap-definition-YRQLILUH-CifOFo_q.js} +1 -1
- package/dist/web/assets/{ordinal-_K3x1fkz.js → ordinal-BJYw-iDX.js} +1 -1
- package/dist/web/assets/packet-RMMSAZCW-34C4o9yj.js +1 -0
- package/dist/web/assets/pie-UPGHQEXC-D9ekKlh9.js +1 -0
- package/dist/web/assets/{pieDiagram-SKSYHLDU-WP0XXw51.js → pieDiagram-SKSYHLDU-BuHUh_fO.js} +1 -1
- package/dist/web/assets/postgres-viewer-B9FYk8sD.js +1 -0
- package/dist/web/assets/{quadrantDiagram-337W2JSQ-FHMogtsh.js → quadrantDiagram-337W2JSQ-Bau_hj6Z.js} +1 -1
- package/dist/web/assets/radar-KQ55EAFF-DEuXOXSD.js +1 -0
- package/dist/web/assets/{requirementDiagram-Z7DCOOCP-BatTxyWb.js → requirementDiagram-Z7DCOOCP-Cq2b-uwp.js} +1 -1
- package/dist/web/assets/{sankeyDiagram-WA2Y5GQK-ClJuW3Hv.js → sankeyDiagram-WA2Y5GQK-DrdGQxWQ.js} +1 -1
- package/dist/web/assets/{sequenceDiagram-2WXFIKYE-ByxQqGgs.js → sequenceDiagram-2WXFIKYE-qPxiTUcS.js} +1 -1
- package/dist/web/assets/settings-store-DWXGVHsE.js +2 -0
- package/dist/web/assets/settings-tab-D-q8pd-5.js +1 -0
- package/dist/web/assets/sqlite-viewer-CDqcTePw.js +1 -0
- package/dist/web/assets/{stateDiagram-RAJIS63D-f8opcZNY.js → stateDiagram-RAJIS63D-Dulj2oa8.js} +1 -1
- package/dist/web/assets/stateDiagram-v2-FVOUBMTO-CAkzLlhk.js +1 -0
- package/dist/web/assets/tab-store-BPeiymiH.js +1 -0
- package/dist/web/assets/{terminal-tab-CCDLZA5Y.js → terminal-tab-wKgpSPAT.js} +2 -2
- package/dist/web/assets/{timeline-definition-YZTLITO2-58BlOSf9.js → timeline-definition-YZTLITO2-BWyDnCYq.js} +1 -1
- package/dist/web/assets/treemap-KZPCXAKY-nc7a1Ia1.js +1 -0
- package/dist/web/assets/use-monaco-theme-CCBTQ0S3.js +11 -0
- package/dist/web/assets/{vennDiagram-LZ73GAT5-BOSy9ma9.js → vennDiagram-LZ73GAT5-B9Iv2bNV.js} +1 -1
- package/dist/web/assets/{xychartDiagram-JWTSCODW-z5MVJauZ.js → xychartDiagram-JWTSCODW-ChXcMzBQ.js} +1 -1
- package/dist/web/index.html +11 -12
- package/dist/web/sw.js +1 -1
- package/docs/code-standards.md +7 -232
- package/docs/codebase-summary.md +3 -9
- package/docs/design-guidelines.md +0 -21
- package/docs/project-changelog.md +1 -115
- package/docs/project-roadmap.md +19 -41
- package/docs/system-architecture.md +15 -212
- package/package.json +2 -3
- package/src/cli/commands/autostart.ts +1 -1
- package/src/cli/commands/restart.ts +1 -9
- package/src/cli/commands/status.ts +0 -19
- package/src/index.ts +3 -2
- package/src/providers/claude-agent-sdk.ts +31 -94
- package/src/providers/mock-provider.ts +1 -6
- package/src/server/index.ts +166 -38
- package/src/server/routes/chat.ts +3 -52
- package/src/server/routes/project-scoped.ts +0 -2
- package/src/server/routes/proxy.ts +53 -46
- package/src/server/routes/tunnel.ts +32 -0
- package/src/server/ws/chat.ts +146 -207
- package/src/services/account-selector.service.ts +8 -16
- package/src/services/account.service.ts +13 -19
- package/src/services/claude-usage.service.ts +11 -48
- package/src/services/cloud.service.ts +6 -10
- package/src/services/db.service.ts +6 -111
- package/src/services/port-tunnel.service.ts +97 -0
- package/src/services/proxy.service.ts +19 -4
- package/src/services/supervisor.ts +25 -285
- package/src/types/api.ts +1 -9
- package/src/types/chat.ts +1 -3
- package/src/web/app.tsx +35 -41
- package/src/web/components/browser/browser-tab.tsx +97 -106
- package/src/web/components/chat/chat-history-bar.tsx +6 -72
- package/src/web/components/chat/chat-tab.tsx +16 -32
- package/src/web/components/chat/message-input.tsx +13 -107
- package/src/web/components/chat/message-list.tsx +15 -27
- package/src/web/components/chat/session-picker.tsx +31 -78
- package/src/web/components/chat/usage-badge.tsx +1 -11
- package/src/web/components/editor/code-editor.tsx +26 -36
- package/src/web/components/layout/command-palette.tsx +1 -3
- package/src/web/components/layout/editor-panel.tsx +18 -162
- package/src/web/components/layout/panel-layout.tsx +1 -17
- package/src/web/components/settings/proxy-settings-section.tsx +42 -40
- package/src/web/hooks/use-chat.ts +201 -211
- package/src/web/hooks/use-global-keybindings.ts +2 -25
- package/src/web/hooks/use-server-reload.ts +0 -9
- package/src/web/hooks/use-url-sync.ts +21 -173
- package/src/web/stores/keybindings-store.ts +0 -1
- package/src/web/stores/panel-store.ts +19 -73
- package/src/web/stores/panel-utils.ts +3 -145
- package/dist/web/assets/api-settings-Bx1GaNmQ.js +0 -1
- package/dist/web/assets/architecture-PBZL5I3N-DEO2f3VD.js +0 -1
- package/dist/web/assets/arrow-up--LjUXLEt.js +0 -1
- package/dist/web/assets/browser-tab-DaHGm_0i.js +0 -1
- package/dist/web/assets/channel-wrd-NHWf.js +0 -1
- package/dist/web/assets/chat-tab-BDYE0KHF.js +0 -8
- package/dist/web/assets/chevron-right-DeV0ehiG.js +0 -1
- package/dist/web/assets/chunk-GLR3WWYH-CzYx4w-r.js +0 -2
- package/dist/web/assets/chunk-HHEYEP7N-HRhYy3kG.js +0 -1
- package/dist/web/assets/chunk-QZHKN3VN-CYaTbeZf.js +0 -1
- package/dist/web/assets/classDiagram-VBA2DB6C-lse8oZoJ.js +0 -1
- package/dist/web/assets/classDiagram-v2-RAHNMMFH-CxkwuInd.js +0 -1
- package/dist/web/assets/clone-LRxlvnMj.js +0 -1
- package/dist/web/assets/code-editor-DTA3c9Y8.js +0 -2
- package/dist/web/assets/csv-preview-DLqYtXxt.js +0 -10
- package/dist/web/assets/database-viewer-DXk79Nel.js +0 -1
- package/dist/web/assets/diff-viewer-HhIcsOQE.js +0 -4
- package/dist/web/assets/dist-DylI9XxN.js +0 -13
- package/dist/web/assets/dist-lF8CoYII.js +0 -41
- package/dist/web/assets/git-graph-CQtWu8yE.js +0 -1
- package/dist/web/assets/gitGraph-HDMCJU4V-Bwna3and.js +0 -1
- package/dist/web/assets/index-CgQXpBb_.css +0 -2
- package/dist/web/assets/index-DEeeRoka.js +0 -37
- package/dist/web/assets/info-3K5VOQVL-_vRxVNUm.js +0 -1
- package/dist/web/assets/infoDiagram-LFFYTUFH-B1CX0pbC.js +0 -2
- package/dist/web/assets/input-BglMT33g.js +0 -1
- package/dist/web/assets/keybindings-store-1CJ7VX57.js +0 -1
- package/dist/web/assets/lib-BQ34Db2e.js +0 -4
- package/dist/web/assets/markdown-renderer-Brj8_LQM.js +0 -69
- package/dist/web/assets/packet-RMMSAZCW-DY5PNnZU.js +0 -1
- package/dist/web/assets/pie-UPGHQEXC-BHncZutv.js +0 -1
- package/dist/web/assets/postgres-viewer-CwkTGmqy.js +0 -1
- package/dist/web/assets/radar-KQ55EAFF-DH0AOkUy.js +0 -1
- package/dist/web/assets/react-dom-Bpkvzu3U.js +0 -1
- package/dist/web/assets/settings-tab-BDE1MsIh.js +0 -1
- package/dist/web/assets/sqlite-viewer-CFYTwgA8.js +0 -1
- package/dist/web/assets/stateDiagram-v2-FVOUBMTO-DrxVDY9q.js +0 -1
- package/dist/web/assets/tab-store-BJw7OCmy.js +0 -1
- package/dist/web/assets/treemap-KZPCXAKY-B2Xkyv-K.js +0 -1
- package/dist/web/assets/use-monaco-theme-CNzekTN3.js +0 -11
- package/docs/streaming-input-guide.md +0 -267
- package/snapshot-state.md +0 -1526
- package/src/server/routes/browser-preview.ts +0 -159
- package/src/server/routes/workspace.ts +0 -35
- package/src/services/cloud-ws.service.ts +0 -227
- package/src/web/components/chat/account-rotation-settings.tsx +0 -163
- package/src/web/components/chat/chat-welcome.tsx +0 -148
- package/src/web/components/editor/csv-preview.tsx +0 -228
- package/src/web/components/editor/editor-breadcrumb.tsx +0 -216
- package/src/web/components/editor/editor-toolbar.tsx +0 -74
- package/src/web/components/shared/connection-lost-overlay.tsx +0 -89
- package/src/web/hooks/use-voice-input.ts +0 -111
- package/src/web/lib/csv-parser.ts +0 -134
- package/src/web/stores/connection-store.ts +0 -39
- package/test-tokens.mjs +0 -212
- /package/dist/web/assets/{api-client-BfBM3I7n.js → api-client-DOElml5u.js} +0 -0
- /package/dist/web/assets/{array-B9UHiPd-.js → array-CYkMkqnU.js} +0 -0
- /package/dist/web/assets/{columns-2-DpsNbZOc.js → columns-2-ChOTgl3e.js} +0 -0
- /package/dist/web/assets/{cytoscape.esm-BW-DbntU.js → cytoscape.esm-HeHO0VhB.js} +0 -0
- /package/dist/web/assets/{defaultLocale-5eAKkKJC.js → defaultLocale-Beh6XjaL.js} +0 -0
- /package/dist/web/assets/{dist-CSJdAyA9.js → dist-BUYzeuKe.js} +0 -0
- /package/dist/web/assets/{init-DlZdxViB.js → init-Rr1s_RiX.js} +0 -0
- /package/dist/web/assets/{isArrayLikeObject-B_v2FtYn.js → isArrayLikeObject-BB-mzMLb.js} +0 -0
- /package/dist/web/assets/{katex-Bqvo_ZG0.js → katex-CKoArbIw.js} +0 -0
- /package/dist/web/assets/{math-069Z4SuC.js → math-B7b0HgJF.js} +0 -0
- /package/dist/web/assets/{path-6uRLdFF7.js → path-BAQ3hXlG.js} +0 -0
- /package/dist/web/assets/{preload-helper-uTix4PVD.js → preload-helper-DeiOTZKJ.js} +0 -0
- /package/dist/web/assets/{react-ER-4DN55.js → react-Dev-wu-s.js} +0 -0
- /package/dist/web/assets/{rough.esm-JX0wREDd.js → rough.esm-Dwml_la6.js} +0 -0
- /package/dist/web/assets/{src-BqX54PbV.js → src-B_cC68fH.js} +0 -0
- /package/dist/web/assets/{table-C7X5UAEI.js → table-COiJDPRA.js} +0 -0
- /package/dist/web/assets/{tag-CCtdV063.js → tag-LMq02LfE.js} +0 -0
- /package/dist/web/assets/{utils-BNytJOb1.js → utils-btZ8C8-R.js} +0 -0
|
@@ -1,159 +0,0 @@
|
|
|
1
|
-
import { Hono } from "hono";
|
|
2
|
-
import { ok, err } from "../../types/api.ts";
|
|
3
|
-
import { ensureCloudflared } from "../../services/cloudflared.service.ts";
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Browser preview API — starts per-port Cloudflare Quick Tunnels so the
|
|
7
|
-
* frontend can iframe any localhost dev server without CORS/path issues.
|
|
8
|
-
*
|
|
9
|
-
* POST /api/preview/tunnel { port: 3000 } → { url: "https://xxx.trycloudflare.com" }
|
|
10
|
-
* DELETE /api/preview/tunnel/:port → stops tunnel for that port
|
|
11
|
-
* GET /api/preview/tunnels → list active tunnels
|
|
12
|
-
*/
|
|
13
|
-
export const browserPreviewRoutes = new Hono();
|
|
14
|
-
|
|
15
|
-
const TUNNEL_URL_REGEX = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
|
|
16
|
-
|
|
17
|
-
interface ActiveTunnel {
|
|
18
|
-
port: number;
|
|
19
|
-
url: string;
|
|
20
|
-
process: import("bun").Subprocess;
|
|
21
|
-
startedAt: number;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/** Active tunnels keyed by port — exported for testing */
|
|
25
|
-
export const activeTunnels = new Map<number, ActiveTunnel>();
|
|
26
|
-
|
|
27
|
-
/** Start a tunnel for a localhost port */
|
|
28
|
-
browserPreviewRoutes.post("/tunnel", async (c) => {
|
|
29
|
-
const body = await c.req.json<{ port: number }>().catch(() => null);
|
|
30
|
-
const port = body?.port;
|
|
31
|
-
if (!port || port < 1 || port > 65535) {
|
|
32
|
-
return c.json(err("Invalid port (1-65535)"), 400);
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
// Return existing tunnel if already running
|
|
36
|
-
const existing = activeTunnels.get(port);
|
|
37
|
-
if (existing) {
|
|
38
|
-
return c.json(ok({ port, url: existing.url }));
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
try {
|
|
42
|
-
const bin = await ensureCloudflared();
|
|
43
|
-
const proc = Bun.spawn(
|
|
44
|
-
[bin, "tunnel", "--url", `http://127.0.0.1:${port}`],
|
|
45
|
-
{ stderr: "pipe", stdout: "ignore", stdin: "ignore" },
|
|
46
|
-
);
|
|
47
|
-
|
|
48
|
-
// Read stderr to find tunnel URL
|
|
49
|
-
const reader = proc.stderr.getReader();
|
|
50
|
-
const decoder = new TextDecoder();
|
|
51
|
-
const url = await new Promise<string>((resolve, reject) => {
|
|
52
|
-
const timeout = setTimeout(() => {
|
|
53
|
-
try { proc.kill(); } catch {}
|
|
54
|
-
reject(new Error("Tunnel timed out after 30s"));
|
|
55
|
-
}, 30_000);
|
|
56
|
-
|
|
57
|
-
let buffer = "";
|
|
58
|
-
let found = false;
|
|
59
|
-
const read = async () => {
|
|
60
|
-
try {
|
|
61
|
-
while (true) {
|
|
62
|
-
const { done, value } = await reader.read();
|
|
63
|
-
if (done) break;
|
|
64
|
-
if (found) continue;
|
|
65
|
-
buffer += decoder.decode(value, { stream: true });
|
|
66
|
-
const match = buffer.match(TUNNEL_URL_REGEX);
|
|
67
|
-
if (match) {
|
|
68
|
-
found = true;
|
|
69
|
-
buffer = "";
|
|
70
|
-
clearTimeout(timeout);
|
|
71
|
-
resolve(match[0]);
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
if (!found) {
|
|
75
|
-
clearTimeout(timeout);
|
|
76
|
-
reject(new Error("cloudflared exited without tunnel URL"));
|
|
77
|
-
}
|
|
78
|
-
} catch (e) {
|
|
79
|
-
if (!found) { clearTimeout(timeout); reject(e); }
|
|
80
|
-
}
|
|
81
|
-
};
|
|
82
|
-
read();
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
activeTunnels.set(port, { port, url, process: proc, startedAt: Date.now() });
|
|
86
|
-
|
|
87
|
-
// Auto-cleanup when process exits
|
|
88
|
-
proc.exited.then(() => activeTunnels.delete(port)).catch(() => activeTunnels.delete(port));
|
|
89
|
-
|
|
90
|
-
console.log(`[preview] tunnel started for port ${port} → ${url}`);
|
|
91
|
-
return c.json(ok({ port, url }));
|
|
92
|
-
} catch (e: any) {
|
|
93
|
-
return c.json(err(e.message || "Failed to start tunnel"), 500);
|
|
94
|
-
}
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
/** Stop a tunnel */
|
|
98
|
-
browserPreviewRoutes.delete("/tunnel/:port{[0-9]+}", (c) => {
|
|
99
|
-
const port = parseInt(c.req.param("port"), 10);
|
|
100
|
-
const tunnel = activeTunnels.get(port);
|
|
101
|
-
if (!tunnel) {
|
|
102
|
-
return c.json(err("No tunnel running for this port"), 404);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
try { tunnel.process.kill(); } catch {}
|
|
106
|
-
activeTunnels.delete(port);
|
|
107
|
-
console.log(`[preview] tunnel stopped for port ${port}`);
|
|
108
|
-
return c.json(ok({ port }));
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
/** List active tunnels */
|
|
112
|
-
browserPreviewRoutes.get("/tunnels", (c) => {
|
|
113
|
-
const list = Array.from(activeTunnels.values()).map((t) => ({
|
|
114
|
-
port: t.port,
|
|
115
|
-
url: t.url,
|
|
116
|
-
startedAt: t.startedAt,
|
|
117
|
-
}));
|
|
118
|
-
return c.json(ok(list));
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
/** Check if a cloudflared process is still alive */
|
|
122
|
-
function isProcessAlive(proc: import("bun").Subprocess): boolean {
|
|
123
|
-
try { process.kill(proc.pid, 0); return true; } catch { return false; }
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/** Remove ghost tunnels (process died or target port no longer listening) */
|
|
127
|
-
async function cleanupGhostTunnels() {
|
|
128
|
-
for (const [port, tunnel] of activeTunnels) {
|
|
129
|
-
// Check if cloudflared process is still running
|
|
130
|
-
if (!isProcessAlive(tunnel.process)) {
|
|
131
|
-
console.log(`[preview] ghost cleanup: tunnel for port ${port} — process dead`);
|
|
132
|
-
activeTunnels.delete(port);
|
|
133
|
-
continue;
|
|
134
|
-
}
|
|
135
|
-
// Check if target port is still listening
|
|
136
|
-
try {
|
|
137
|
-
const conn = await Bun.connect({ hostname: "127.0.0.1", port, socket: {
|
|
138
|
-
data() {}, open(s) { s.end(); }, error() {}, close() {},
|
|
139
|
-
}});
|
|
140
|
-
conn.end();
|
|
141
|
-
} catch {
|
|
142
|
-
// Port not listening — kill tunnel
|
|
143
|
-
console.log(`[preview] ghost cleanup: tunnel for port ${port} — port not listening`);
|
|
144
|
-
try { tunnel.process.kill(); } catch {}
|
|
145
|
-
activeTunnels.delete(port);
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// Run ghost cleanup every 30s
|
|
151
|
-
setInterval(cleanupGhostTunnels, 30_000);
|
|
152
|
-
|
|
153
|
-
/** Cleanup all tunnels on server shutdown */
|
|
154
|
-
export function stopAllPreviewTunnels() {
|
|
155
|
-
for (const [port, tunnel] of activeTunnels) {
|
|
156
|
-
try { tunnel.process.kill(); } catch {}
|
|
157
|
-
activeTunnels.delete(port);
|
|
158
|
-
}
|
|
159
|
-
}
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
import { Hono } from "hono";
|
|
2
|
-
import { getWorkspace, setWorkspace } from "../../services/db.service.ts";
|
|
3
|
-
import { ok, err } from "../../types/api.ts";
|
|
4
|
-
|
|
5
|
-
type Env = { Variables: { projectPath: string; projectName: string } };
|
|
6
|
-
|
|
7
|
-
export const workspaceRoutes = new Hono<Env>();
|
|
8
|
-
|
|
9
|
-
/** GET /workspace — load saved workspace layout */
|
|
10
|
-
workspaceRoutes.get("/", (c) => {
|
|
11
|
-
try {
|
|
12
|
-
const projectName = c.get("projectName");
|
|
13
|
-
const row = getWorkspace(projectName);
|
|
14
|
-
if (!row) return c.json(ok(null));
|
|
15
|
-
return c.json(ok({
|
|
16
|
-
layout: JSON.parse(row.layout_json),
|
|
17
|
-
updatedAt: row.updated_at,
|
|
18
|
-
}));
|
|
19
|
-
} catch (e) {
|
|
20
|
-
return c.json(err((e as Error).message), 500);
|
|
21
|
-
}
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
/** PUT /workspace — save workspace layout */
|
|
25
|
-
workspaceRoutes.put("/", async (c) => {
|
|
26
|
-
try {
|
|
27
|
-
const projectName = c.get("projectName");
|
|
28
|
-
const body = await c.req.json<{ layout: unknown }>();
|
|
29
|
-
if (!body.layout) return c.json(err("Missing layout"), 400);
|
|
30
|
-
const updatedAt = setWorkspace(projectName, JSON.stringify(body.layout));
|
|
31
|
-
return c.json(ok({ updatedAt }));
|
|
32
|
-
} catch (e) {
|
|
33
|
-
return c.json(err((e as Error).message), 500);
|
|
34
|
-
}
|
|
35
|
-
});
|
|
@@ -1,227 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Cloud WebSocket client — persistent connection from supervisor to PPM Cloud.
|
|
3
|
-
* Auto-reconnects with exponential backoff + jitter. Queues messages when disconnected.
|
|
4
|
-
*/
|
|
5
|
-
import { appendFileSync } from "node:fs";
|
|
6
|
-
import { resolve } from "node:path";
|
|
7
|
-
import { homedir } from "node:os";
|
|
8
|
-
|
|
9
|
-
// ─── Types (must match Cloud's ws-types.ts) ─────────
|
|
10
|
-
interface WsMessage {
|
|
11
|
-
type: string;
|
|
12
|
-
id?: string;
|
|
13
|
-
timestamp: string;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
interface HeartbeatMsg extends WsMessage {
|
|
17
|
-
type: "heartbeat";
|
|
18
|
-
tunnelUrl: string | null;
|
|
19
|
-
state: string;
|
|
20
|
-
appVersion: string;
|
|
21
|
-
availableVersion: string | null;
|
|
22
|
-
serverPid: number | null;
|
|
23
|
-
uptime: number;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
interface StateChangeMsg extends WsMessage {
|
|
27
|
-
type: "state_change";
|
|
28
|
-
from: string;
|
|
29
|
-
to: string;
|
|
30
|
-
reason: string;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
interface CommandAckMsg extends WsMessage {
|
|
34
|
-
type: "command_ack";
|
|
35
|
-
id: string;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
interface CommandResultMsg extends WsMessage {
|
|
39
|
-
type: "command_result";
|
|
40
|
-
id: string;
|
|
41
|
-
success: boolean;
|
|
42
|
-
error?: string;
|
|
43
|
-
data?: Record<string, unknown>;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
type OutboundMsg = HeartbeatMsg | StateChangeMsg | CommandAckMsg | CommandResultMsg;
|
|
47
|
-
|
|
48
|
-
interface CommandMsg extends WsMessage {
|
|
49
|
-
type: "command";
|
|
50
|
-
id: string;
|
|
51
|
-
action: string;
|
|
52
|
-
params?: Record<string, unknown>;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
type CommandHandler = (cmd: CommandMsg) => void;
|
|
56
|
-
|
|
57
|
-
// ─── Constants ──────────────────────────────────────
|
|
58
|
-
const BACKOFF_STEPS = [1000, 2000, 4000, 8000, 15000, 30000, 60000];
|
|
59
|
-
const MAX_QUEUE_SIZE = 50;
|
|
60
|
-
const HEARTBEAT_INTERVAL_MS = 60_000; // 60s via WS
|
|
61
|
-
|
|
62
|
-
// ─── State ──────────────────────────────────────────
|
|
63
|
-
let ws: WebSocket | null = null;
|
|
64
|
-
let connected = false;
|
|
65
|
-
let reconnecting = false;
|
|
66
|
-
let reconnectAttempt = 0;
|
|
67
|
-
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
68
|
-
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
69
|
-
let commandHandler: CommandHandler | null = null;
|
|
70
|
-
let outboundQueue: OutboundMsg[] = [];
|
|
71
|
-
let wsUrl = "";
|
|
72
|
-
let shouldConnect = false;
|
|
73
|
-
|
|
74
|
-
// Credentials for first-message auth
|
|
75
|
-
let deviceId = "";
|
|
76
|
-
let secretKey = "";
|
|
77
|
-
|
|
78
|
-
// For heartbeat payload
|
|
79
|
-
let getHeartbeatData: (() => HeartbeatMsg) | null = null;
|
|
80
|
-
|
|
81
|
-
// ─── Public API ─────────────────────────────────────
|
|
82
|
-
|
|
83
|
-
export function connect(opts: {
|
|
84
|
-
cloudUrl: string;
|
|
85
|
-
deviceId: string;
|
|
86
|
-
secretKey: string;
|
|
87
|
-
heartbeatFn: () => HeartbeatMsg;
|
|
88
|
-
}): void {
|
|
89
|
-
// No secret_key in URL — auth via first message after connect
|
|
90
|
-
wsUrl = `${opts.cloudUrl.replace(/^http/, "ws")}/ws/device`;
|
|
91
|
-
deviceId = opts.deviceId;
|
|
92
|
-
secretKey = opts.secretKey;
|
|
93
|
-
getHeartbeatData = opts.heartbeatFn;
|
|
94
|
-
shouldConnect = true;
|
|
95
|
-
reconnectAttempt = 0;
|
|
96
|
-
doConnect();
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
export function disconnect(): void {
|
|
100
|
-
shouldConnect = false;
|
|
101
|
-
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
|
102
|
-
if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
|
|
103
|
-
if (ws) {
|
|
104
|
-
try { ws.close(1000, "shutdown"); } catch {}
|
|
105
|
-
ws = null;
|
|
106
|
-
}
|
|
107
|
-
connected = false;
|
|
108
|
-
outboundQueue = [];
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
export function send(msg: OutboundMsg): void {
|
|
112
|
-
if (connected && ws?.readyState === WebSocket.OPEN) {
|
|
113
|
-
ws.send(JSON.stringify(msg));
|
|
114
|
-
} else {
|
|
115
|
-
outboundQueue.push(msg);
|
|
116
|
-
if (outboundQueue.length > MAX_QUEUE_SIZE) outboundQueue.shift();
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
export function onCommand(handler: CommandHandler): void {
|
|
121
|
-
commandHandler = handler;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
export function isConnected(): boolean {
|
|
125
|
-
return connected;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// ─── Internal ───────────────────────────────────────
|
|
129
|
-
|
|
130
|
-
function doConnect(): void {
|
|
131
|
-
if (!shouldConnect || reconnecting) return;
|
|
132
|
-
reconnecting = true;
|
|
133
|
-
|
|
134
|
-
// Capture local ref — if a reconnect replaces `ws` before this socket's
|
|
135
|
-
// handlers fire, stale handlers must not reset module-level state.
|
|
136
|
-
let sock: WebSocket;
|
|
137
|
-
try {
|
|
138
|
-
sock = new WebSocket(wsUrl);
|
|
139
|
-
ws = sock;
|
|
140
|
-
} catch {
|
|
141
|
-
reconnecting = false;
|
|
142
|
-
scheduleReconnect("constructor");
|
|
143
|
-
return;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
sock.onopen = () => {
|
|
147
|
-
if (ws !== sock) return; // stale — newer connection replaced us
|
|
148
|
-
reconnecting = false;
|
|
149
|
-
reconnectAttempt = 0;
|
|
150
|
-
log("INFO", "Cloud WS connected, sending auth");
|
|
151
|
-
|
|
152
|
-
// Send auth as first message — server must process this before any other msg
|
|
153
|
-
sock.send(JSON.stringify({
|
|
154
|
-
type: "auth",
|
|
155
|
-
deviceId,
|
|
156
|
-
secretKey,
|
|
157
|
-
timestamp: new Date().toISOString(),
|
|
158
|
-
version: 1,
|
|
159
|
-
}));
|
|
160
|
-
|
|
161
|
-
// Delay setting connected + sending heartbeat to let server process auth.
|
|
162
|
-
// Server's authenticateDevice() is async (DB lookup), so messages sent
|
|
163
|
-
// immediately after auth arrive before authenticated=true → 4002 reject.
|
|
164
|
-
setTimeout(() => {
|
|
165
|
-
if (ws !== sock) return; // replaced during delay
|
|
166
|
-
connected = true;
|
|
167
|
-
|
|
168
|
-
// Flush queued messages
|
|
169
|
-
while (outboundQueue.length > 0 && connected) {
|
|
170
|
-
const msg = outboundQueue.shift()!;
|
|
171
|
-
sock.send(JSON.stringify(msg));
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
// Send immediate heartbeat
|
|
175
|
-
if (getHeartbeatData) send(getHeartbeatData());
|
|
176
|
-
|
|
177
|
-
// Start periodic heartbeat
|
|
178
|
-
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
179
|
-
heartbeatTimer = setInterval(() => {
|
|
180
|
-
if (getHeartbeatData && connected) send(getHeartbeatData());
|
|
181
|
-
}, HEARTBEAT_INTERVAL_MS);
|
|
182
|
-
}, 500); // 500ms for DB auth round-trip
|
|
183
|
-
};
|
|
184
|
-
|
|
185
|
-
sock.onmessage = (event) => {
|
|
186
|
-
try {
|
|
187
|
-
const msg = JSON.parse(String(event.data)) as CommandMsg;
|
|
188
|
-
if (msg.type === "command" && commandHandler) {
|
|
189
|
-
commandHandler(msg);
|
|
190
|
-
}
|
|
191
|
-
} catch {} // ignore malformed
|
|
192
|
-
};
|
|
193
|
-
|
|
194
|
-
sock.onclose = (event) => {
|
|
195
|
-
if (ws !== sock) return; // stale — ignore close from replaced connection
|
|
196
|
-
log("WARN", `Cloud WS closed: code=${event.code} reason=${event.reason || ""}`);
|
|
197
|
-
connected = false;
|
|
198
|
-
reconnecting = false;
|
|
199
|
-
ws = null;
|
|
200
|
-
if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
|
|
201
|
-
if (shouldConnect) scheduleReconnect("onclose");
|
|
202
|
-
};
|
|
203
|
-
|
|
204
|
-
sock.onerror = (event) => {
|
|
205
|
-
log("ERROR", `Cloud WS error: ${String(event)}`);
|
|
206
|
-
};
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
function scheduleReconnect(source = "unknown"): void {
|
|
210
|
-
if (!shouldConnect || reconnectTimer) return;
|
|
211
|
-
const base = BACKOFF_STEPS[Math.min(reconnectAttempt, BACKOFF_STEPS.length - 1)]!;
|
|
212
|
-
// Add ±30% jitter to prevent thundering herd after Cloud deploy
|
|
213
|
-
const jitter = base * (0.7 + Math.random() * 0.6);
|
|
214
|
-
const delay = Math.round(jitter);
|
|
215
|
-
reconnectAttempt++;
|
|
216
|
-
log("WARN", `Cloud WS reconnect in ${delay}ms (attempt #${reconnectAttempt}) src=${source}`);
|
|
217
|
-
reconnectTimer = setTimeout(() => {
|
|
218
|
-
reconnectTimer = null;
|
|
219
|
-
doConnect();
|
|
220
|
-
}, delay);
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
function log(level: string, msg: string): void {
|
|
224
|
-
const ts = new Date().toISOString();
|
|
225
|
-
const logFile = resolve(process.env.PPM_HOME || resolve(homedir(), ".ppm"), "ppm.log");
|
|
226
|
-
try { appendFileSync(logFile, `[${ts}] [${level}] [cloud-ws] ${msg}\n`); } catch {}
|
|
227
|
-
}
|
|
@@ -1,163 +0,0 @@
|
|
|
1
|
-
import { useState, useEffect, useSyncExternalStore } from "react";
|
|
2
|
-
import { Settings, X } from "lucide-react";
|
|
3
|
-
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
|
4
|
-
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
5
|
-
import { cn } from "@/lib/utils";
|
|
6
|
-
import {
|
|
7
|
-
getAccountSettings,
|
|
8
|
-
updateAccountSettings,
|
|
9
|
-
type AccountSettings,
|
|
10
|
-
} from "../../lib/api-settings";
|
|
11
|
-
|
|
12
|
-
interface AccountRotationSettingsProps {
|
|
13
|
-
open: boolean;
|
|
14
|
-
onOpenChange: (open: boolean) => void;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
const mdQuery = typeof window !== "undefined" ? window.matchMedia("(min-width: 768px)") : null;
|
|
18
|
-
function subscribeMedia(cb: () => void) {
|
|
19
|
-
mdQuery?.addEventListener("change", cb);
|
|
20
|
-
return () => mdQuery?.removeEventListener("change", cb);
|
|
21
|
-
}
|
|
22
|
-
function getIsDesktop() {
|
|
23
|
-
return mdQuery?.matches ?? true;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function SettingsContent() {
|
|
27
|
-
const [settings, setSettings] = useState<AccountSettings | null>(null);
|
|
28
|
-
const [loading, setLoading] = useState(true);
|
|
29
|
-
|
|
30
|
-
useEffect(() => {
|
|
31
|
-
setLoading(true);
|
|
32
|
-
getAccountSettings()
|
|
33
|
-
.then(setSettings)
|
|
34
|
-
.finally(() => setLoading(false));
|
|
35
|
-
}, []);
|
|
36
|
-
|
|
37
|
-
if (loading) {
|
|
38
|
-
return <p className="text-xs text-text-subtle py-4 text-center">Loading...</p>;
|
|
39
|
-
}
|
|
40
|
-
if (!settings) {
|
|
41
|
-
return <p className="text-xs text-text-subtle py-4 text-center">Failed to load settings</p>;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
return (
|
|
45
|
-
<div className="space-y-4">
|
|
46
|
-
{/* Strategy */}
|
|
47
|
-
<div className="space-y-1.5">
|
|
48
|
-
<label className="text-xs font-medium text-text-primary">Rotation Strategy</label>
|
|
49
|
-
<Select
|
|
50
|
-
value={settings.strategy}
|
|
51
|
-
onValueChange={async (v) => {
|
|
52
|
-
const updated = await updateAccountSettings({ strategy: v as AccountSettings["strategy"] });
|
|
53
|
-
setSettings(updated);
|
|
54
|
-
}}
|
|
55
|
-
>
|
|
56
|
-
<SelectTrigger className="w-full h-9 text-xs">
|
|
57
|
-
<SelectValue />
|
|
58
|
-
</SelectTrigger>
|
|
59
|
-
<SelectContent>
|
|
60
|
-
<SelectItem value="round-robin">Round-robin</SelectItem>
|
|
61
|
-
<SelectItem value="fill-first">Fill-first</SelectItem>
|
|
62
|
-
<SelectItem value="lowest-usage">Lowest usage</SelectItem>
|
|
63
|
-
</SelectContent>
|
|
64
|
-
</Select>
|
|
65
|
-
<p className="text-[10px] text-text-subtle">
|
|
66
|
-
{settings.strategy === "round-robin" && "Cycles through accounts evenly"}
|
|
67
|
-
{settings.strategy === "fill-first" && "Uses one account until its limit, then moves on"}
|
|
68
|
-
{settings.strategy === "lowest-usage" && "Picks the account with the lowest current usage"}
|
|
69
|
-
</p>
|
|
70
|
-
</div>
|
|
71
|
-
|
|
72
|
-
{/* Max Retry */}
|
|
73
|
-
<div className="space-y-1.5">
|
|
74
|
-
<label className="text-xs font-medium text-text-primary">Max Retry</label>
|
|
75
|
-
<input
|
|
76
|
-
type="number"
|
|
77
|
-
min={0}
|
|
78
|
-
value={settings.maxRetry}
|
|
79
|
-
className="w-full h-9 text-xs border rounded-md px-3 bg-background"
|
|
80
|
-
onChange={async (e) => {
|
|
81
|
-
const v = parseInt(e.target.value, 10);
|
|
82
|
-
if (!isNaN(v) && v >= 0) {
|
|
83
|
-
const updated = await updateAccountSettings({ maxRetry: v });
|
|
84
|
-
setSettings(updated);
|
|
85
|
-
}
|
|
86
|
-
}}
|
|
87
|
-
/>
|
|
88
|
-
<p className="text-[10px] text-text-subtle">
|
|
89
|
-
How many accounts to try on failure. 0 = try all available accounts.
|
|
90
|
-
</p>
|
|
91
|
-
</div>
|
|
92
|
-
|
|
93
|
-
{/* Active accounts */}
|
|
94
|
-
<div className="flex items-center justify-between text-xs border-t border-border pt-3">
|
|
95
|
-
<span className="text-text-subtle">Active accounts</span>
|
|
96
|
-
<span className="font-medium text-text-primary">{settings.activeCount}</span>
|
|
97
|
-
</div>
|
|
98
|
-
</div>
|
|
99
|
-
);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
export function AccountRotationSettings({ open, onOpenChange }: AccountRotationSettingsProps) {
|
|
103
|
-
const isDesktop = useSyncExternalStore(subscribeMedia, getIsDesktop);
|
|
104
|
-
|
|
105
|
-
if (!open) return null;
|
|
106
|
-
|
|
107
|
-
// Desktop: Dialog
|
|
108
|
-
if (isDesktop) {
|
|
109
|
-
return (
|
|
110
|
-
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
111
|
-
<DialogContent className="sm:max-w-sm">
|
|
112
|
-
<DialogHeader>
|
|
113
|
-
<DialogTitle className="text-sm flex items-center gap-2">
|
|
114
|
-
<Settings className="size-4" /> Rotation & Retry
|
|
115
|
-
</DialogTitle>
|
|
116
|
-
</DialogHeader>
|
|
117
|
-
<SettingsContent />
|
|
118
|
-
</DialogContent>
|
|
119
|
-
</Dialog>
|
|
120
|
-
);
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// Mobile: Bottom sheet
|
|
124
|
-
return (
|
|
125
|
-
<>
|
|
126
|
-
<div
|
|
127
|
-
className="fixed inset-0 z-50 transition-opacity duration-200 opacity-100"
|
|
128
|
-
onClick={() => onOpenChange(false)}
|
|
129
|
-
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
|
|
130
|
-
/>
|
|
131
|
-
<div
|
|
132
|
-
className={cn(
|
|
133
|
-
"fixed bottom-0 left-0 right-0 z-50 bg-background rounded-t-2xl border-t border-border shadow-2xl",
|
|
134
|
-
"transition-transform duration-300 ease-out max-h-[85vh] overflow-y-auto",
|
|
135
|
-
"translate-y-0",
|
|
136
|
-
)}
|
|
137
|
-
>
|
|
138
|
-
{/* Drag handle */}
|
|
139
|
-
<div className="flex justify-center pt-3 pb-1">
|
|
140
|
-
<div className="w-10 h-1 rounded-full bg-border" />
|
|
141
|
-
</div>
|
|
142
|
-
|
|
143
|
-
{/* Header */}
|
|
144
|
-
<div className="flex items-center justify-between px-4 py-2 border-b border-border">
|
|
145
|
-
<span className="text-sm font-semibold flex items-center gap-2">
|
|
146
|
-
<Settings className="size-4" /> Rotation & Retry
|
|
147
|
-
</span>
|
|
148
|
-
<button
|
|
149
|
-
onClick={() => onOpenChange(false)}
|
|
150
|
-
className="flex items-center justify-center size-7 rounded-md hover:bg-surface-elevated transition-colors"
|
|
151
|
-
>
|
|
152
|
-
<X className="size-4" />
|
|
153
|
-
</button>
|
|
154
|
-
</div>
|
|
155
|
-
|
|
156
|
-
{/* Content */}
|
|
157
|
-
<div className="px-4 py-4 pb-8">
|
|
158
|
-
<SettingsContent />
|
|
159
|
-
</div>
|
|
160
|
-
</div>
|
|
161
|
-
</>
|
|
162
|
-
);
|
|
163
|
-
}
|