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.
- package/.claude/settings.local.json +25 -0
- package/.dockerignore +21 -0
- package/.github/workflows/deploy-hetzner.yml +71 -0
- package/CLAUDE.md +3 -0
- package/Dockerfile +32 -0
- package/PLAN.md +36 -0
- package/SPEC.md +121 -0
- package/dashboard/app/backup/page.tsx +327 -0
- package/dashboard/app/events/page.tsx +185 -0
- package/dashboard/app/globals.css +3 -0
- package/dashboard/app/layout.tsx +21 -0
- package/dashboard/app/login/page.tsx +135 -0
- package/dashboard/app/organizations/[id]/page.tsx +887 -0
- package/dashboard/app/organizations/page.tsx +238 -0
- package/dashboard/app/page.tsx +226 -0
- package/dashboard/app/settings/page.tsx +92 -0
- package/dashboard/app/status/page.tsx +78 -0
- package/dashboard/app/users/page.tsx +128 -0
- package/dashboard/components/ErrorBanner.tsx +14 -0
- package/dashboard/components/Layout.tsx +166 -0
- package/dashboard/design_model/event_detail.html +91 -0
- package/dashboard/design_model/event_list.html +122 -0
- package/dashboard/design_model/event_list_dark.html +122 -0
- package/dashboard/design_model/hypercrm_tickets_tab_active_view.png +0 -0
- package/dashboard/design_model/hypercrm_tickets_tab_empty_state.png +0 -0
- package/dashboard/design_model/ticket_active_view.html +126 -0
- package/dashboard/design_model/ticket_empty_state.html +105 -0
- package/dashboard/design_model/ticket_empty_state_dark.html +107 -0
- package/dashboard/lib/api.ts +163 -0
- package/dashboard/lib/apiBase.ts +16 -0
- package/dashboard/middleware.ts +22 -0
- package/dashboard/next-env.d.ts +6 -0
- package/dashboard/next.config.js +13 -0
- package/dashboard/package.json +32 -0
- package/dashboard/postcss.config.js +6 -0
- package/dashboard/tailwind.config.js +12 -0
- package/dashboard/tsconfig.json +20 -0
- package/deploy/nginx/hypercrm-dashboard.conf +15 -0
- package/deploy/nginx/hypercrm.conf +29 -0
- package/dev-examples/README.md +8 -0
- package/dev-examples/test-wallet-features.js +308 -0
- package/dev-examples/test-widget-csp.html +110 -0
- package/docker-start.sh +51 -0
- package/docs/DEPLOY_HETZNER_SQLITE.md +186 -0
- package/nodemon.json +11 -0
- package/package.json +48 -35
- package/packages/hypercrm/.hypercrm-package +1 -0
- package/{assets → packages/hypercrm/assets}/widget.js +26 -2
- package/packages/hypercrm/hypercrm-1.0.2.tgz +0 -0
- package/packages/hypercrm/package.json +41 -0
- package/packages/hypercrm/src/index.ts +134 -0
- package/packages/hypercrm/src/react-shim.d.ts +5 -0
- package/packages/hypercrm/src/react.ts +86 -0
- package/packages/hypercrm/tsconfig.json +17 -0
- package/packages/widget/src/index.ts +134 -0
- package/packages/widget/src/react.ts +86 -0
- package/public/test-client/demo.html +445 -0
- package/public/widget/assets/discord-logo.png +0 -0
- package/public/widget/assets/icon-announcement.svg +5 -0
- package/public/widget/assets/icon-attach.svg +4 -0
- package/public/widget/assets/icon-back.svg +4 -0
- package/public/widget/assets/icon-chat.svg +5 -0
- package/public/widget/assets/icon-close.svg +4 -0
- package/public/widget/assets/icon-file.svg +5 -0
- package/public/widget/assets/icon-lock.svg +5 -0
- package/public/widget/assets/icon-retry.svg +5 -0
- package/public/widget/assets/icon-send.svg +5 -0
- package/public/widget/assets/telegram-logo.svg +16 -0
- package/public/widget/widget.js +2982 -0
- package/scripts/backup/_env.sh +51 -0
- package/scripts/backup/b2-backup.sh +77 -0
- package/scripts/backup/b2-restore-if-missing.sh +85 -0
- package/scripts/backup/b2-smoke-test.sh +90 -0
- package/scripts/build-widget.mjs +27 -0
- package/scripts/check-attachments.js +50 -0
- package/scripts/init-db.js +54 -0
- package/scripts/pack-hypercrm.mjs +230 -0
- package/scripts/reset-tickets.js +45 -0
- package/scripts/simple-check.js +29 -0
- package/src/app.js +284 -0
- package/src/bots/adminTopicManager.js +257 -0
- package/src/bots/discord.js +735 -0
- package/src/bots/index.js +171 -0
- package/src/bots/telegram.js +1987 -0
- package/src/config/config.json +24 -0
- package/src/config/index.js +20 -0
- package/src/middleware/auth.js +83 -0
- package/src/middleware/errorHandler.js +59 -0
- package/src/middleware/upload.js +83 -0
- package/src/models/index.js +370 -0
- package/src/routes/admin.js +145 -0
- package/src/routes/auth.js +295 -0
- package/src/routes/events.js +319 -0
- package/src/routes/organizations.js +715 -0
- package/src/routes/telegram.js +112 -0
- package/src/routes/tickets.js +434 -0
- package/src/seed.js +20 -0
- package/src/utils/attachments.js +30 -0
- package/src/utils/backupRunner.js +346 -0
- package/src/utils/crypto.js +57 -0
- package/src/utils/events.js +6 -0
- package/src/utils/fileProxyToken.js +72 -0
- package/src/utils/helpers.js +47 -0
- package/src/utils/logger.js +51 -0
- package/src/utils/orgSettings.js +29 -0
- package/src/utils/telegramAdmin.js +80 -0
- package/src/utils/telegramAdminTopic.js +62 -0
- package/test/widget.test.js +532 -0
- package/test-api.sh +185 -0
- package/test-realtime.sh +69 -0
- package/tsconfig.json +12 -0
- package/uploads/.gitkeep +0 -0
- package/widget-react-pkg/package.json +32 -0
- package/widget-react-pkg/src/ensureWidget.ts +41 -0
- package/widget-react-pkg/src/global.d.ts +10 -0
- package/widget-react-pkg/src/index.ts +2 -0
- package/widget-react-pkg/src/modules.d.ts +4 -0
- package/widget-react-pkg/src/react-shim.d.ts +5 -0
- package/widget-react-pkg/src/useHyperCRMWidget.ts +36 -0
- package/widget-react-pkg/tsconfig.json +13 -0
- package/widget-react-pkg/tsup.config.ts +31 -0
- package/dist/index.d.ts +0 -41
- package/dist/index.js +0 -104
- package/dist/react.d.ts +0 -4
- package/dist/react.js +0 -65
- /package/{README.md → packages/hypercrm/README.md} +0 -0
- /package/{assets → packages/hypercrm/assets}/discord-logo.png +0 -0
- /package/{assets → packages/hypercrm/assets}/icon-announcement.svg +0 -0
- /package/{assets → packages/hypercrm/assets}/icon-attach.svg +0 -0
- /package/{assets → packages/hypercrm/assets}/icon-back.svg +0 -0
- /package/{assets → packages/hypercrm/assets}/icon-chat.svg +0 -0
- /package/{assets → packages/hypercrm/assets}/icon-close.svg +0 -0
- /package/{assets → packages/hypercrm/assets}/icon-file.svg +0 -0
- /package/{assets → packages/hypercrm/assets}/icon-lock.svg +0 -0
- /package/{assets → packages/hypercrm/assets}/icon-retry.svg +0 -0
- /package/{assets → packages/hypercrm/assets}/icon-send.svg +0 -0
- /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
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
|
+
}
|