hypercrm 1.0.1 → 1.0.2

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 (137) hide show
  1. package/.claude/settings.local.json +25 -0
  2. package/.dockerignore +21 -0
  3. package/.github/workflows/deploy-hetzner.yml +71 -0
  4. package/CLAUDE.md +3 -0
  5. package/Dockerfile +32 -0
  6. package/PLAN.md +36 -0
  7. package/SPEC.md +121 -0
  8. package/dashboard/app/backup/page.tsx +327 -0
  9. package/dashboard/app/events/page.tsx +185 -0
  10. package/dashboard/app/globals.css +3 -0
  11. package/dashboard/app/layout.tsx +21 -0
  12. package/dashboard/app/login/page.tsx +135 -0
  13. package/dashboard/app/organizations/[id]/page.tsx +887 -0
  14. package/dashboard/app/organizations/page.tsx +238 -0
  15. package/dashboard/app/page.tsx +226 -0
  16. package/dashboard/app/settings/page.tsx +92 -0
  17. package/dashboard/app/status/page.tsx +78 -0
  18. package/dashboard/app/users/page.tsx +128 -0
  19. package/dashboard/components/ErrorBanner.tsx +14 -0
  20. package/dashboard/components/Layout.tsx +166 -0
  21. package/dashboard/design_model/event_detail.html +91 -0
  22. package/dashboard/design_model/event_list.html +122 -0
  23. package/dashboard/design_model/event_list_dark.html +122 -0
  24. package/dashboard/design_model/hypercrm_tickets_tab_active_view.png +0 -0
  25. package/dashboard/design_model/hypercrm_tickets_tab_empty_state.png +0 -0
  26. package/dashboard/design_model/ticket_active_view.html +126 -0
  27. package/dashboard/design_model/ticket_empty_state.html +105 -0
  28. package/dashboard/design_model/ticket_empty_state_dark.html +107 -0
  29. package/dashboard/lib/api.ts +163 -0
  30. package/dashboard/lib/apiBase.ts +16 -0
  31. package/dashboard/middleware.ts +22 -0
  32. package/dashboard/next-env.d.ts +6 -0
  33. package/dashboard/next.config.js +13 -0
  34. package/dashboard/package.json +32 -0
  35. package/dashboard/postcss.config.js +6 -0
  36. package/dashboard/tailwind.config.js +12 -0
  37. package/dashboard/tsconfig.json +20 -0
  38. package/deploy/nginx/hypercrm-dashboard.conf +15 -0
  39. package/deploy/nginx/hypercrm.conf +29 -0
  40. package/dev-examples/README.md +8 -0
  41. package/dev-examples/test-wallet-features.js +308 -0
  42. package/dev-examples/test-widget-csp.html +110 -0
  43. package/docker-start.sh +51 -0
  44. package/docs/DEPLOY_HETZNER_SQLITE.md +186 -0
  45. package/nodemon.json +11 -0
  46. package/package.json +48 -35
  47. package/packages/hypercrm/.hypercrm-package +1 -0
  48. package/{assets → packages/hypercrm/assets}/widget.js +26 -2
  49. package/packages/hypercrm/hypercrm-1.0.2.tgz +0 -0
  50. package/packages/hypercrm/package.json +41 -0
  51. package/packages/hypercrm/src/index.ts +134 -0
  52. package/packages/hypercrm/src/react-shim.d.ts +5 -0
  53. package/packages/hypercrm/src/react.ts +86 -0
  54. package/packages/hypercrm/tsconfig.json +17 -0
  55. package/packages/widget/src/index.ts +134 -0
  56. package/packages/widget/src/react.ts +86 -0
  57. package/public/test-client/demo.html +445 -0
  58. package/public/widget/assets/discord-logo.png +0 -0
  59. package/public/widget/assets/icon-announcement.svg +5 -0
  60. package/public/widget/assets/icon-attach.svg +4 -0
  61. package/public/widget/assets/icon-back.svg +4 -0
  62. package/public/widget/assets/icon-chat.svg +5 -0
  63. package/public/widget/assets/icon-close.svg +4 -0
  64. package/public/widget/assets/icon-file.svg +5 -0
  65. package/public/widget/assets/icon-lock.svg +5 -0
  66. package/public/widget/assets/icon-retry.svg +5 -0
  67. package/public/widget/assets/icon-send.svg +5 -0
  68. package/public/widget/assets/telegram-logo.svg +16 -0
  69. package/public/widget/widget.js +2982 -0
  70. package/scripts/backup/_env.sh +51 -0
  71. package/scripts/backup/b2-backup.sh +77 -0
  72. package/scripts/backup/b2-restore-if-missing.sh +85 -0
  73. package/scripts/backup/b2-smoke-test.sh +90 -0
  74. package/scripts/build-widget.mjs +27 -0
  75. package/scripts/check-attachments.js +50 -0
  76. package/scripts/init-db.js +54 -0
  77. package/scripts/pack-hypercrm.mjs +230 -0
  78. package/scripts/reset-tickets.js +45 -0
  79. package/scripts/simple-check.js +29 -0
  80. package/src/app.js +284 -0
  81. package/src/bots/adminTopicManager.js +257 -0
  82. package/src/bots/discord.js +735 -0
  83. package/src/bots/index.js +171 -0
  84. package/src/bots/telegram.js +1987 -0
  85. package/src/config/config.json +24 -0
  86. package/src/config/index.js +20 -0
  87. package/src/middleware/auth.js +83 -0
  88. package/src/middleware/errorHandler.js +59 -0
  89. package/src/middleware/upload.js +83 -0
  90. package/src/models/index.js +370 -0
  91. package/src/routes/admin.js +145 -0
  92. package/src/routes/auth.js +295 -0
  93. package/src/routes/events.js +319 -0
  94. package/src/routes/organizations.js +715 -0
  95. package/src/routes/telegram.js +112 -0
  96. package/src/routes/tickets.js +434 -0
  97. package/src/seed.js +20 -0
  98. package/src/utils/attachments.js +30 -0
  99. package/src/utils/backupRunner.js +346 -0
  100. package/src/utils/crypto.js +57 -0
  101. package/src/utils/events.js +6 -0
  102. package/src/utils/fileProxyToken.js +72 -0
  103. package/src/utils/helpers.js +47 -0
  104. package/src/utils/logger.js +51 -0
  105. package/src/utils/orgSettings.js +29 -0
  106. package/src/utils/telegramAdmin.js +80 -0
  107. package/src/utils/telegramAdminTopic.js +62 -0
  108. package/test/widget.test.js +532 -0
  109. package/test-api.sh +185 -0
  110. package/test-realtime.sh +69 -0
  111. package/tsconfig.json +12 -0
  112. package/uploads/.gitkeep +0 -0
  113. package/widget-react-pkg/package.json +32 -0
  114. package/widget-react-pkg/src/ensureWidget.ts +41 -0
  115. package/widget-react-pkg/src/global.d.ts +10 -0
  116. package/widget-react-pkg/src/index.ts +2 -0
  117. package/widget-react-pkg/src/modules.d.ts +4 -0
  118. package/widget-react-pkg/src/react-shim.d.ts +5 -0
  119. package/widget-react-pkg/src/useHyperCRMWidget.ts +36 -0
  120. package/widget-react-pkg/tsconfig.json +13 -0
  121. package/widget-react-pkg/tsup.config.ts +31 -0
  122. package/dist/index.d.ts +0 -41
  123. package/dist/index.js +0 -104
  124. package/dist/react.d.ts +0 -4
  125. package/dist/react.js +0 -65
  126. /package/{README.md → packages/hypercrm/README.md} +0 -0
  127. /package/{assets → packages/hypercrm/assets}/discord-logo.png +0 -0
  128. /package/{assets → packages/hypercrm/assets}/icon-announcement.svg +0 -0
  129. /package/{assets → packages/hypercrm/assets}/icon-attach.svg +0 -0
  130. /package/{assets → packages/hypercrm/assets}/icon-back.svg +0 -0
  131. /package/{assets → packages/hypercrm/assets}/icon-chat.svg +0 -0
  132. /package/{assets → packages/hypercrm/assets}/icon-close.svg +0 -0
  133. /package/{assets → packages/hypercrm/assets}/icon-file.svg +0 -0
  134. /package/{assets → packages/hypercrm/assets}/icon-lock.svg +0 -0
  135. /package/{assets → packages/hypercrm/assets}/icon-retry.svg +0 -0
  136. /package/{assets → packages/hypercrm/assets}/icon-send.svg +0 -0
  137. /package/{assets → packages/hypercrm/assets}/telegram-logo.svg +0 -0
@@ -0,0 +1,25 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(npm init:*)",
5
+ "Bash(npm install:*)",
6
+ "Bash(mkdir:*)",
7
+ "Bash(npm start)",
8
+ "Bash(node:*)",
9
+ "Bash(npm test)",
10
+ "Bash(npx create-next-app:*)",
11
+ "Bash(npm run seed:*)",
12
+ "Bash(npm uninstall:*)",
13
+ "Bash(npm run dev:*)",
14
+ "Bash(codex exec \"테스트 계획:\n1. MetaMask 지갑 연결 테스트\n2. 티켓 생성 (메시지만 입력)\n3. 지갑 기반 티켓 목록 조회\n4. 티켓 상세보기 및 답변\n5. 대시보드에서 파일 첨부 답변\n\n이 기능들을 Puppeteer로 테스트하는 스크립트를 작성해줘. \n- test-client는 http://localhost:3000/test-client/demo.html\n- dashboard는 http://localhost:3001\n- MetaMask 대신 mock wallet address 사용 (0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb)\n- 파일 업로드는 실제 파일 생성해서 테스트\n- 각 단계마다 성공/실패 명확히 출력\n- 오류 발견시 상세한 피드백 제공\n- 코드는 수정하지 말고 피드백만 제공\")",
15
+ "Bash(./test-api.sh:*)",
16
+ "Bash(curl:*)",
17
+ "Bash(chmod:*)",
18
+ "Bash(./test-realtime.sh:*)",
19
+ "Bash(pkill:*)",
20
+ "Bash(kill:*)"
21
+ ],
22
+ "deny": [],
23
+ "ask": []
24
+ }
25
+ }
package/.dockerignore ADDED
@@ -0,0 +1,21 @@
1
+ node_modules
2
+ .git
3
+ .gitignore
4
+ README.md
5
+ .env
6
+ .next
7
+ .env.local
8
+ .env.production
9
+ .env.development
10
+ npm-debug.log*
11
+ yarn-debug.log*
12
+ yarn-error.log*
13
+ dashboard/node_modules
14
+ dashboard/.next
15
+ dashboard/out
16
+ *.md
17
+ coverage
18
+ .nyc_output
19
+ database.sqlite
20
+ uploads/*
21
+ !uploads/.gitkeep
@@ -0,0 +1,71 @@
1
+ name: Deploy to Hetzner (sqlite)
2
+
3
+ on:
4
+ push:
5
+ branches: [ sqlite ]
6
+ workflow_dispatch:
7
+
8
+ jobs:
9
+ deploy:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - name: Validate SSH key secret
13
+ run: |
14
+ if [ -z "$SSH_KEY" ]; then
15
+ echo "HETZNER_SSH_KEY is empty."
16
+ exit 1
17
+ fi
18
+ echo "SSH key length: ${#SSH_KEY}"
19
+ if ! printf '%s' "$SSH_KEY" | grep -q "BEGIN .*PRIVATE KEY"; then
20
+ echo "HETZNER_SSH_KEY does not look like a private key."
21
+ exit 1
22
+ fi
23
+ env:
24
+ SSH_KEY: ${{ secrets.HETZNER_SSH_KEY }}
25
+ - name: Deploy via SSH
26
+ uses: appleboy/ssh-action@v1.0.3
27
+ with:
28
+ host: ${{ secrets.HETZNER_HOST }}
29
+ username: ${{ secrets.HETZNER_USER }}
30
+ key: ${{ secrets.HETZNER_SSH_KEY }}
31
+ debug: true
32
+ script: |
33
+ set -e
34
+ APP_DIR="/srv/hypercrm/current"
35
+ BRANCH="sqlite"
36
+ if [ ! -d "$APP_DIR/.git" ]; then
37
+ echo "Missing git checkout in $APP_DIR. Clone the repo on the server first."
38
+ exit 1
39
+ fi
40
+
41
+ cd "$APP_DIR"
42
+ if [ -f ".env" ]; then
43
+ echo "Stashing local .env to avoid merge conflicts."
44
+ git stash push -u -m "deploy-env" .env
45
+ fi
46
+ git fetch origin "$BRANCH"
47
+ git checkout "$BRANCH"
48
+ git pull --ff-only origin "$BRANCH"
49
+ if git stash list | grep -q "deploy-env"; then
50
+ git stash pop
51
+ fi
52
+
53
+ npm install --omit=dev
54
+
55
+ cd "$APP_DIR/dashboard"
56
+ npm install
57
+ npm run build
58
+
59
+ if pm2 describe hypercrm-api >/dev/null 2>&1; then
60
+ PORT=3001 NODE_ENV=production pm2 restart hypercrm-api --update-env
61
+ else
62
+ PORT=3001 NODE_ENV=production pm2 start npm --name hypercrm-api --cwd "$APP_DIR" -- start
63
+ fi
64
+
65
+ if pm2 describe hypercrm-dashboard >/dev/null 2>&1; then
66
+ PORT=3002 NODE_ENV=production pm2 restart hypercrm-dashboard --update-env
67
+ else
68
+ PORT=3002 NODE_ENV=production pm2 start npm --name hypercrm-dashboard --cwd "$APP_DIR/dashboard" -- start
69
+ fi
70
+
71
+ pm2 save
package/CLAUDE.md ADDED
@@ -0,0 +1,3 @@
1
+ 처음 작업을 시작하기 전에 계획을 단계별로 상세하게 ultrathink 해서 짜. 계획에서 변경사항이 있을지 확인 받은 후 하나씩 실천해.
2
+ workflow.md 파일에 최초 계획을 작성하고, 그 이후 각 작업이 끝날 때마다 하나씩 올라가는 버전 이름과 함께 작업한 의도와 진행내용을 추가기록해 나가고, 테스트 방법과 기준을 설정해줘.
3
+ 반드시 기억해, 작업이 끝난후엔 작업 중 실행했던 모든 bash는 종료해줘.
package/Dockerfile ADDED
@@ -0,0 +1,32 @@
1
+ FROM node:18-alpine
2
+
3
+ # Install gcloud CLI for secret access
4
+ RUN apk add --no-cache curl python3 py3-pip bash
5
+ RUN curl -sSL https://sdk.cloud.google.com | bash
6
+ ENV PATH="$PATH:/root/google-cloud-sdk/bin"
7
+
8
+ WORKDIR /app
9
+
10
+ # Copy package files
11
+ COPY package*.json ./
12
+
13
+ # Install dependencies
14
+ RUN npm install --production
15
+
16
+ # Copy source code (exclude dashboard)
17
+ COPY src ./src
18
+ COPY public ./public
19
+ COPY *.js ./
20
+ COPY *.md ./
21
+
22
+ # Create uploads directory
23
+ RUN mkdir -p /app/uploads
24
+
25
+ # Expose ports
26
+ EXPOSE 3000
27
+
28
+ # Start script
29
+ COPY docker-start.sh /app/
30
+ RUN chmod +x /app/docker-start.sh
31
+
32
+ CMD ["/app/docker-start.sh"]
package/PLAN.md ADDED
@@ -0,0 +1,36 @@
1
+ # Production Refactor Plan (SQLite + Backblaze)
2
+
3
+ This plan keeps HyperCRM on a single-node SQLite setup and focuses on durability and backups rather than migrating to Postgres.
4
+
5
+ ## Objectives
6
+ - Keep SQLite as the only database, stored under `./uploads/db`.
7
+ - Keep uploads under `./uploads/media` with a predictable directory layout.
8
+ - Back up DB + uploads to Backblaze B2 on a schedule.
9
+ - Restore from B2 on boot only when local data is missing.
10
+
11
+ ## Workstreams
12
+
13
+ ### A) Database (SQLite)
14
+ 1. Ensure the DB file path is fixed to `./uploads/db/database.sqlite`.
15
+ 2. Add a safe DB snapshot step for backups (sqlite backup API).
16
+ 3. Optional: add retention policy for closed tickets to manage DB size.
17
+
18
+ ### B) Backups (Backblaze B2)
19
+ 1. Backup script that:
20
+ - Creates a SQLite snapshot.
21
+ - Syncs `db/` + `media/` to `b2://$BUCKET_NAME/$B2_PREFIX`.
22
+ 2. Restore-on-start script that:
23
+ - Runs only when DB or uploads are missing/empty.
24
+ - Pulls from B2 and restores to local paths.
25
+ 3. Scheduling:
26
+ - Use cron/systemd timer (e.g., twice daily).
27
+ - Keep only the latest version in B2 bucket settings.
28
+
29
+ ### C) Uploads
30
+ 1. Store all uploads in `./uploads/media`.
31
+ 2. Keep directory layout consistent for easier backup and restore.
32
+
33
+ ## Acceptance Criteria
34
+ - App boots with SQLite only (no Postgres/Neon references).
35
+ - Backups run successfully on schedule and keep bucket size under free tier.
36
+ - Restore-on-start repopulates DB + uploads when local data is missing.
package/SPEC.md ADDED
@@ -0,0 +1,121 @@
1
+ # HyperCRM Spec
2
+
3
+ ## Overview
4
+ HyperCRM is a multi-channel support system for Web3 teams. It centralizes tickets from:
5
+ - Web widget
6
+ - Telegram (admin bot + org-scoped user bots)
7
+ - Discord
8
+ - Dashboard
9
+
10
+ The API is an Express app backed by SQLite. The dashboard is a Next.js app. The widget is a static JS embed served by the API.
11
+
12
+ ## Components
13
+ - API server: `src/app.js`
14
+ - Express + Socket.IO
15
+ - Serves `/api/*`, `/widget/*`, `/uploads/*`, `/test-client/*`
16
+ - Dashboard: `dashboard/` (Next.js)
17
+ - Widget: `public/widget/widget.js`
18
+ - Bots:
19
+ - Telegram: `src/bots/telegram.js`
20
+ - Discord: `src/bots/discord.js`
21
+ - Storage:
22
+ - SQLite: `uploads/db/database.sqlite` (default)
23
+ - Media uploads: `uploads/media/YYYY-MM/`
24
+
25
+ ## Data Model
26
+ - Organization
27
+ - `settings` JSON for Telegram/Discord/Widget config
28
+ - `settings.telegram.adminGroupId` links the admin group
29
+ - User
30
+ - `role` in {admin, agent, viewer}
31
+ - `telegramUserId` for admin handle resolution
32
+ - Ticket
33
+ - `source` in {widget, telegram, discord}
34
+ - `status` in {open, pending, resolved, closed}
35
+ - `metadata` stores platform ids and admin topic info
36
+ - Message
37
+ - `senderType` in {customer, agent, system}
38
+ - Attachment
39
+ - URLs stored for Telegram/Discord relays
40
+ - Local files stored under `uploads/media`
41
+ - BotConfig
42
+ - Per-org tokens for Telegram/Discord user bots
43
+ - Secrets can be encrypted at rest
44
+ - Event
45
+ - Used for announcements (title/content/cta/banner)
46
+ - Sequence
47
+ - Counter for ticket numbering
48
+
49
+ ## Ticket Lifecycle
50
+ 1. Create
51
+ - Widget: creates ticket + first message, opens conversation UI
52
+ - Telegram: org user bot creates ticket on first message
53
+ - Discord: bot creates a private ticket channel
54
+ 2. Admin topic
55
+ - Telegram admin bot creates a forum topic per ticket
56
+ - Topic title includes source prefix and ticket number
57
+ 3. Message relay
58
+ - Customer messages mirror into admin topic
59
+ - Admin replies relay back to the original platform
60
+ - Widget updates via Socket.IO
61
+ 4. Close/Delete
62
+ - Close sets status and notifies widget/user
63
+ - Delete removes records and deletes Telegram topic / Discord channel
64
+
65
+ ## Telegram
66
+ - Admin bot
67
+ - Token from `TELEGRAM_ADMIN_BOT_TOKEN`
68
+ - `/creategroup` in a group binds the org to that admin group
69
+ - `/panel` shows Control Center buttons
70
+ - `/cleanup`, `/nick`, `/delnick`, `/nicks` for maintenance
71
+ - User bots
72
+ - One bot per org
73
+ - Token stored in `BotConfig` (not from env)
74
+ - `/start`, `/ticket`, `/status`, `/close` with persistent keyboard
75
+
76
+ ## Discord
77
+ - Requires `DISCORD_BOT_TOKEN`
78
+ - Creates ticket channels and a ticket panel
79
+ - Relays messages to Telegram admin topics
80
+ - Closes channel when ticket is closed/deleted
81
+
82
+ ## Widget
83
+ - Embed script: `/widget/widget.js`
84
+ - Requires `X-API-Key` (organization apiKey)
85
+ - Shows a single active ticket thread and announcements
86
+ - Uses Socket.IO for live updates
87
+
88
+ ## API Surface (high-level)
89
+ - `/api/auth/*`
90
+ - `/api/tickets/*`
91
+ - `/api/organizations/*`
92
+ - `/api/events/*` (announcements)
93
+ - `/api/telegram/*` (admin bot info + file proxy)
94
+ - `/api/health`
95
+
96
+ ## Environment Variables
97
+ Required (production):
98
+ - `JWT_SECRET`
99
+ - `TELEGRAM_ADMIN_BOT_TOKEN`
100
+ - `DISCORD_BOT_TOKEN`
101
+ - `ALLOWED_ORIGINS`
102
+
103
+ Recommended:
104
+ - `SECRET_KEY` (encrypt stored tokens)
105
+ - `SQLITE_PATH` (override db path)
106
+
107
+ Optional:
108
+ - `ORG_ID` (pin organization selection)
109
+ - Backups: `BUCKET_NAME`, `BUCKET_ID`, `BUCKET_appkey` (or `B2_KEY_ID` + `B2_APP_KEY`)
110
+ - Dashboard build: `NEXT_PUBLIC_BASE_DOMAIN`
111
+
112
+ ## Backups
113
+ - Scripts in `scripts/backup/`
114
+ - Back up `uploads/db/database.sqlite` and `uploads/media/` to Backblaze B2
115
+ - API start runs restore-if-missing before boot when the B2 env is present
116
+
117
+ ## Defaults
118
+ - API dev port: 3000
119
+ - API prod port: set by `PORT` (defaults to 3001 in `package.json`)
120
+ - Dashboard dev port: 3001
121
+ - Dashboard prod port: 3002
@@ -0,0 +1,327 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useMemo, useState } from 'react';
4
+ import { useRouter } from 'next/navigation';
5
+ import { adminApi, authApi } from '@/lib/api';
6
+ import ErrorBanner from '@/components/ErrorBanner';
7
+
8
+ type Snapshot = {
9
+ dbPath: string;
10
+ dbExists: boolean;
11
+ dbBytes: number;
12
+ mediaDir: string;
13
+ mediaExists: boolean;
14
+ mediaCount: number;
15
+ mediaBytes: number;
16
+ totalBytes: number;
17
+ };
18
+
19
+ type RunStatus = {
20
+ id?: string;
21
+ type?: 'backup' | 'restore';
22
+ status?: 'running' | 'success' | 'failed' | 'canceling' | 'canceled';
23
+ startedAt?: string;
24
+ finishedAt?: string;
25
+ exitCode?: number | null;
26
+ snapshot?: Snapshot;
27
+ postSnapshot?: Snapshot;
28
+ error?: string;
29
+ };
30
+
31
+ type BackupStatus = {
32
+ config: {
33
+ bucketName: string;
34
+ prefix: string;
35
+ keyIdSet: boolean;
36
+ appKeySet: boolean;
37
+ bin: string;
38
+ ready: boolean;
39
+ missing: string[];
40
+ };
41
+ paths: {
42
+ dbPath: string;
43
+ mediaDir: string;
44
+ };
45
+ local: Snapshot;
46
+ backup: RunStatus | null;
47
+ restore: RunStatus | null;
48
+ history: RunStatus[];
49
+ logs: {
50
+ backup: string;
51
+ restore: string;
52
+ };
53
+ running: {
54
+ backup: boolean;
55
+ restore: boolean;
56
+ };
57
+ };
58
+
59
+ const formatBytes = (bytes: number) => {
60
+ if (!bytes || bytes <= 0) return '0 B';
61
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
62
+ let idx = 0;
63
+ let value = bytes;
64
+ while (value >= 1024 && idx < units.length - 1) {
65
+ value /= 1024;
66
+ idx += 1;
67
+ }
68
+ return `${value.toFixed(value >= 10 || idx === 0 ? 0 : 1)} ${units[idx]}`;
69
+ };
70
+
71
+ const formatDate = (iso?: string) => {
72
+ if (!iso) return '-';
73
+ const d = new Date(iso);
74
+ if (Number.isNaN(d.getTime())) return iso;
75
+ return d.toLocaleString();
76
+ };
77
+
78
+ const StatusBadge = ({ status }: { status?: string }) => {
79
+ const label = status || 'idle';
80
+ const colors =
81
+ status === 'success'
82
+ ? 'bg-green-100 text-green-700'
83
+ : status === 'failed'
84
+ ? 'bg-red-100 text-red-700'
85
+ : status === 'running' || status === 'canceling'
86
+ ? 'bg-amber-100 text-amber-700'
87
+ : status === 'canceled'
88
+ ? 'bg-gray-100 text-gray-600'
89
+ : 'bg-gray-100 text-gray-600';
90
+ return <span className={`inline-block px-2 py-0.5 text-xs rounded-full ${colors}`}>{label}</span>;
91
+ };
92
+
93
+ export default function BackupPage() {
94
+ const router = useRouter();
95
+ const [status, setStatus] = useState<BackupStatus | null>(null);
96
+ const [loading, setLoading] = useState(true);
97
+ const [busy, setBusy] = useState<'backup' | 'restore' | ''>('');
98
+ const [error, setError] = useState('');
99
+
100
+ const load = async (silent = false) => {
101
+ try {
102
+ if (!silent) setLoading(true);
103
+ const me = await authApi.me().catch(() => null);
104
+ if (!me?.user?.isSuperAdmin) {
105
+ router.replace('/');
106
+ return;
107
+ }
108
+ const data = await adminApi.backupStatus();
109
+ setStatus(data);
110
+ } catch (e: any) {
111
+ setError(e?.message || 'Failed to load backup status');
112
+ } finally {
113
+ if (!silent) setLoading(false);
114
+ }
115
+ };
116
+
117
+ useEffect(() => {
118
+ load();
119
+ }, []);
120
+
121
+ useEffect(() => {
122
+ if (!status?.running?.backup && !status?.running?.restore) return;
123
+ const interval = setInterval(() => load(true), 5000);
124
+ return () => clearInterval(interval);
125
+ }, [status?.running?.backup, status?.running?.restore]);
126
+
127
+ const runBackup = async () => {
128
+ setBusy('backup');
129
+ setError('');
130
+ try {
131
+ await adminApi.runBackup();
132
+ setTimeout(() => load(true), 1500);
133
+ } catch (e: any) {
134
+ setError(e?.message || 'Failed to start backup');
135
+ } finally {
136
+ setBusy('');
137
+ }
138
+ };
139
+
140
+ const runRestore = async () => {
141
+ if (!confirm('Restore from Backblaze if local data is missing?')) return;
142
+ setBusy('restore');
143
+ setError('');
144
+ try {
145
+ await adminApi.runRestore();
146
+ setTimeout(() => load(true), 1500);
147
+ } catch (e: any) {
148
+ setError(e?.message || 'Failed to start restore');
149
+ } finally {
150
+ setBusy('');
151
+ }
152
+ };
153
+
154
+ const cancelRun = async (type: 'backup' | 'restore') => {
155
+ setBusy(type);
156
+ setError('');
157
+ try {
158
+ await adminApi.cancelBackup(type);
159
+ setTimeout(() => load(true), 1500);
160
+ } catch (e: any) {
161
+ setError(e?.message || 'Failed to cancel job');
162
+ } finally {
163
+ setBusy('');
164
+ }
165
+ };
166
+
167
+ const summary = useMemo(() => {
168
+ if (!status?.local) return null;
169
+ return {
170
+ total: formatBytes(status.local.totalBytes || 0),
171
+ db: formatBytes(status.local.dbBytes || 0),
172
+ media: formatBytes(status.local.mediaBytes || 0),
173
+ mediaCount: status.local.mediaCount || 0,
174
+ };
175
+ }, [status]);
176
+
177
+ return (
178
+ <div className="space-y-6">
179
+ <div className="flex items-center justify-between flex-wrap gap-3">
180
+ <div>
181
+ <h1 className="text-2xl font-bold text-gray-900">Backup</h1>
182
+ <p className="text-sm text-gray-600">Manual controls and recent backup history.</p>
183
+ </div>
184
+ <button
185
+ type="button"
186
+ onClick={() => load()}
187
+ className="px-3 py-2 rounded-lg bg-gray-100 hover:bg-gray-200"
188
+ >
189
+ Refresh
190
+ </button>
191
+ </div>
192
+
193
+ <ErrorBanner message={error} onClose={() => setError('')} />
194
+
195
+ {loading || !status ? (
196
+ <div>Loading…</div>
197
+ ) : (
198
+ <>
199
+ <div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
200
+ <div className="bg-white rounded-lg shadow p-5">
201
+ <h2 className="text-sm font-semibold text-gray-700 mb-2">Configuration</h2>
202
+ <div className="text-sm text-gray-600 space-y-1">
203
+ <div><span className="text-gray-400">Bucket:</span> {status.config.bucketName || '-'}</div>
204
+ <div><span className="text-gray-400">Prefix:</span> {status.config.prefix || '-'}</div>
205
+ <div><span className="text-gray-400">B2 CLI:</span> {status.config.bin || 'Not found'}</div>
206
+ <div className="pt-1">
207
+ <StatusBadge status={status.config.ready ? 'success' : 'failed'} />{' '}
208
+ <span className="text-xs text-gray-500">
209
+ {status.config.ready ? 'Ready' : `Missing: ${status.config.missing.join(', ')}`}
210
+ </span>
211
+ </div>
212
+ </div>
213
+ </div>
214
+
215
+ <div className="bg-white rounded-lg shadow p-5">
216
+ <h2 className="text-sm font-semibold text-gray-700 mb-2">Local Data</h2>
217
+ <div className="text-sm text-gray-600 space-y-1">
218
+ <div><span className="text-gray-400">DB:</span> {summary?.db || '-'}</div>
219
+ <div><span className="text-gray-400">Media:</span> {summary?.media || '-'} ({summary?.mediaCount ?? 0} files)</div>
220
+ <div><span className="text-gray-400">Total:</span> {summary?.total || '-'}</div>
221
+ <div className="text-xs text-gray-400">DB path: {status.paths.dbPath}</div>
222
+ <div className="text-xs text-gray-400">Media dir: {status.paths.mediaDir}</div>
223
+ </div>
224
+ </div>
225
+
226
+ <div className="bg-white rounded-lg shadow p-5">
227
+ <h2 className="text-sm font-semibold text-gray-700 mb-2">Actions</h2>
228
+ <div className="flex flex-col gap-2">
229
+ <button
230
+ type="button"
231
+ onClick={runBackup}
232
+ disabled={busy === 'backup' || status.running.backup || !status.config.ready}
233
+ className="px-3 py-2 rounded-lg bg-purple-600 text-white hover:bg-purple-700 disabled:opacity-50"
234
+ >
235
+ {status.running.backup || busy === 'backup' ? 'Backup running…' : 'Run Backup'}
236
+ </button>
237
+ <button
238
+ type="button"
239
+ onClick={runRestore}
240
+ disabled={busy === 'restore' || status.running.restore || !status.config.ready}
241
+ className="px-3 py-2 rounded-lg bg-gray-100 hover:bg-gray-200 disabled:opacity-50"
242
+ >
243
+ {status.running.restore || busy === 'restore' ? 'Restore running…' : 'Restore If Missing'}
244
+ </button>
245
+ {(status.running.backup || status.running.restore) && (
246
+ <button
247
+ type="button"
248
+ onClick={() => cancelRun(status.running.restore ? 'restore' : 'backup')}
249
+ className="px-3 py-2 rounded-lg bg-red-50 text-red-600 hover:bg-red-100"
250
+ >
251
+ Cancel {status.running.restore ? 'Restore' : 'Backup'}
252
+ </button>
253
+ )}
254
+ {!status.config.ready && (
255
+ <p className="text-xs text-amber-600">Configure BUCKET_NAME/B2 keys and install the b2 CLI to enable actions.</p>
256
+ )}
257
+ </div>
258
+ </div>
259
+ </div>
260
+
261
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
262
+ <div className="bg-white rounded-lg shadow p-5">
263
+ <h2 className="text-sm font-semibold text-gray-700 mb-2 flex items-center gap-2">
264
+ Latest Backup <StatusBadge status={status.backup?.status} />
265
+ </h2>
266
+ <div className="text-xs text-gray-500 space-y-1 mb-3">
267
+ <div>Started: {formatDate(status.backup?.startedAt)}</div>
268
+ <div>Finished: {formatDate(status.backup?.finishedAt)}</div>
269
+ <div>Exit: {status.backup?.exitCode ?? '-'}</div>
270
+ </div>
271
+ <pre className="text-xs bg-gray-50 border rounded p-3 max-h-60 overflow-auto">
272
+ {status.logs.backup?.trim() || 'No backup logs yet.'}
273
+ </pre>
274
+ </div>
275
+
276
+ <div className="bg-white rounded-lg shadow p-5">
277
+ <h2 className="text-sm font-semibold text-gray-700 mb-2 flex items-center gap-2">
278
+ Latest Restore <StatusBadge status={status.restore?.status} />
279
+ </h2>
280
+ <div className="text-xs text-gray-500 space-y-1 mb-3">
281
+ <div>Started: {formatDate(status.restore?.startedAt)}</div>
282
+ <div>Finished: {formatDate(status.restore?.finishedAt)}</div>
283
+ <div>Exit: {status.restore?.exitCode ?? '-'}</div>
284
+ </div>
285
+ <pre className="text-xs bg-gray-50 border rounded p-3 max-h-60 overflow-auto">
286
+ {status.logs.restore?.trim() || 'No restore logs yet.'}
287
+ </pre>
288
+ </div>
289
+ </div>
290
+
291
+ <div className="bg-white rounded-lg shadow p-5">
292
+ <h2 className="text-sm font-semibold text-gray-700 mb-3">History</h2>
293
+ <div className="overflow-auto">
294
+ <table className="min-w-full text-sm">
295
+ <thead className="text-xs text-gray-500 border-b">
296
+ <tr>
297
+ <th className="text-left py-2 pr-3">Type</th>
298
+ <th className="text-left py-2 pr-3">Status</th>
299
+ <th className="text-left py-2 pr-3">Started</th>
300
+ <th className="text-left py-2 pr-3">Finished</th>
301
+ <th className="text-left py-2 pr-3">Exit</th>
302
+ </tr>
303
+ </thead>
304
+ <tbody>
305
+ {(status.history || []).length === 0 && (
306
+ <tr>
307
+ <td colSpan={5} className="py-4 text-gray-400">No backup history yet.</td>
308
+ </tr>
309
+ )}
310
+ {(status.history || []).slice().reverse().map((entry) => (
311
+ <tr key={entry.id} className="border-b last:border-0">
312
+ <td className="py-2 pr-3">{entry.type}</td>
313
+ <td className="py-2 pr-3"><StatusBadge status={entry.status} /></td>
314
+ <td className="py-2 pr-3 text-xs text-gray-500">{formatDate(entry.startedAt)}</td>
315
+ <td className="py-2 pr-3 text-xs text-gray-500">{formatDate(entry.finishedAt)}</td>
316
+ <td className="py-2 pr-3 text-xs text-gray-500">{entry.exitCode ?? '-'}</td>
317
+ </tr>
318
+ ))}
319
+ </tbody>
320
+ </table>
321
+ </div>
322
+ </div>
323
+ </>
324
+ )}
325
+ </div>
326
+ );
327
+ }