Prepare Alpine artifact deploy workflow
This commit is contained in:
24
deploy/openrc/abelbirdnest-web
Normal file
24
deploy/openrc/abelbirdnest-web
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
#!/sbin/openrc-run
|
||||||
|
|
||||||
|
name="AbelBirdnest Stock"
|
||||||
|
description="AbelBirdnest Stock Next.js standalone server"
|
||||||
|
|
||||||
|
directory="/var/www/abelbirdnest-web/AbelBirdNest-Stock/current"
|
||||||
|
env_file="/var/www/abelbirdnest-web/AbelBirdNest-Stock/.env.production"
|
||||||
|
command="/bin/sh"
|
||||||
|
command_args="-lc 'set -a; . \"${env_file}\"; set +a; exec /usr/bin/node server.js'"
|
||||||
|
command_user="abelbirdnest:abelbirdnest"
|
||||||
|
command_background="yes"
|
||||||
|
pidfile="/run/${RC_SVCNAME}.pid"
|
||||||
|
|
||||||
|
output_log="/var/log/abelbirdnest-web.log"
|
||||||
|
error_log="/var/log/abelbirdnest-web.err"
|
||||||
|
|
||||||
|
export NODE_ENV="production"
|
||||||
|
export PORT="3007"
|
||||||
|
export NODE_OPTIONS="--max-old-space-size=512"
|
||||||
|
|
||||||
|
depend() {
|
||||||
|
need net
|
||||||
|
after postgresql
|
||||||
|
}
|
||||||
@ -2,21 +2,26 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
IMAGE="${DOCKER_IMAGE:-node:20-bookworm-slim}"
|
IMAGE="${DOCKER_IMAGE:-node:20-alpine}"
|
||||||
ARTIFACT_NAME="${ARTIFACT_NAME:-abelbirdnest-release.tar.gz}"
|
RELEASE_STAMP="${RELEASE_STAMP:-$(date +%Y%m%d-%H%M%S)}"
|
||||||
|
ARTIFACT_NAME="${ARTIFACT_NAME:-abelbirdnest-release-${RELEASE_STAMP}.tar.gz}"
|
||||||
NODE_MEMORY_MB="${NODE_MEMORY_MB:-4096}"
|
NODE_MEMORY_MB="${NODE_MEMORY_MB:-4096}"
|
||||||
|
|
||||||
cd "$ROOT_DIR"
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
if ! command -v docker >/dev/null 2>&1; then
|
||||||
|
echo "Docker is required to build the Alpine Linux release artifact." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
docker run --rm \
|
docker run --rm \
|
||||||
-v "$ROOT_DIR":/src:ro \
|
-v "$ROOT_DIR":/src:ro \
|
||||||
-v "$ROOT_DIR":/out \
|
-v "$ROOT_DIR":/out \
|
||||||
-w / \
|
-w / \
|
||||||
"$IMAGE" \
|
"$IMAGE" \
|
||||||
bash -lc '
|
sh -lc '
|
||||||
set -euo pipefail
|
set -eu
|
||||||
apt-get update >/dev/null
|
apk add --no-cache openssl >/dev/null
|
||||||
apt-get install -y --no-install-recommends openssl >/dev/null
|
|
||||||
rm -rf /work
|
rm -rf /work
|
||||||
mkdir -p /work
|
mkdir -p /work
|
||||||
cp -R /src/. /work/
|
cp -R /src/. /work/
|
||||||
|
|||||||
@ -7,11 +7,22 @@ if [ "$#" -lt 1 ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
TARGET="$1"
|
TARGET="$1"
|
||||||
REMOTE_BASE_DIR="${2:-/var/www/abelbirdnest-web}"
|
REMOTE_BASE_DIR="${2:-/var/www/abelbirdnest-web/AbelBirdNest-Stock}"
|
||||||
ARTIFACT_NAME="${ARTIFACT_NAME:-abelbirdnest-release.tar.gz}"
|
ARTIFACT_NAME="${ARTIFACT_NAME:-$(ls -t abelbirdnest-release-*.tar.gz 2>/dev/null | head -n 1)}"
|
||||||
RELEASE_NAME="${RELEASE_NAME:-$(date +%Y%m%d-%H%M%S)}"
|
RELEASE_NAME="${RELEASE_NAME:-$(date +%Y%m%d-%H%M%S)}"
|
||||||
REMOTE_RELEASE_DIR="$REMOTE_BASE_DIR/releases/$RELEASE_NAME"
|
REMOTE_RELEASE_DIR="$REMOTE_BASE_DIR/releases/$RELEASE_NAME"
|
||||||
|
|
||||||
|
if [ -z "$ARTIFACT_NAME" ]; then
|
||||||
|
echo "No artifact found. Run ./deploy/scripts/build-linux-release.sh first." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$ARTIFACT_NAME" ]; then
|
||||||
|
echo "Artifact not found: $ARTIFACT_NAME" >&2
|
||||||
|
echo "Run ./deploy/scripts/build-linux-release.sh first." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
scp "$ARTIFACT_NAME" "$TARGET:$REMOTE_BASE_DIR/"
|
scp "$ARTIFACT_NAME" "$TARGET:$REMOTE_BASE_DIR/"
|
||||||
|
|
||||||
ssh "$TARGET" "mkdir -p '$REMOTE_RELEASE_DIR' && tar -xzf '$REMOTE_BASE_DIR/$ARTIFACT_NAME' -C '$REMOTE_RELEASE_DIR' && ln -sfn '$REMOTE_RELEASE_DIR' '$REMOTE_BASE_DIR/current'"
|
ssh "$TARGET" "mkdir -p '$REMOTE_RELEASE_DIR' && tar -xzf '$REMOTE_BASE_DIR/$ARTIFACT_NAME' -C '$REMOTE_RELEASE_DIR' && ln -sfn '$REMOTE_RELEASE_DIR' '$REMOTE_BASE_DIR/current'"
|
||||||
|
|||||||
@ -4,11 +4,12 @@ After=network.target postgresql.service
|
|||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
WorkingDirectory=/var/www/abelbirdnest-web/AbelBirdNest-Stock
|
WorkingDirectory=/var/www/abelbirdnest-web/AbelBirdNest-Stock/current
|
||||||
Environment=NODE_ENV=production
|
Environment=NODE_ENV=production
|
||||||
Environment=PORT=3007
|
Environment=PORT=3007
|
||||||
|
Environment=NODE_OPTIONS=--max-old-space-size=512
|
||||||
EnvironmentFile=/var/www/abelbirdnest-web/AbelBirdNest-Stock/.env.production
|
EnvironmentFile=/var/www/abelbirdnest-web/AbelBirdNest-Stock/.env.production
|
||||||
ExecStart=/usr/bin/npm run start
|
ExecStart=/usr/bin/node server.js
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
User=abelbirdnest
|
User=abelbirdnest
|
||||||
|
|||||||
@ -112,6 +112,7 @@ Dokumen ini menyimpan konteks kerja terbaru setelah rangkaian patch mobile, logi
|
|||||||
- `native`
|
- `native`
|
||||||
- `darwin-arm64`
|
- `darwin-arm64`
|
||||||
- `debian-openssl-3.0.x`
|
- `debian-openssl-3.0.x`
|
||||||
|
- `linux-musl-openssl-3.0.x`
|
||||||
- File:
|
- File:
|
||||||
- [prisma/schema.prisma](/Users/wirabasalamah/Documents/Codex/abelbirdnest-web/prisma/schema.prisma)
|
- [prisma/schema.prisma](/Users/wirabasalamah/Documents/Codex/abelbirdnest-web/prisma/schema.prisma)
|
||||||
|
|
||||||
@ -122,26 +123,71 @@ Dokumen ini menyimpan konteks kerja terbaru setelah rangkaian patch mobile, logi
|
|||||||
- Script:
|
- Script:
|
||||||
- [deploy/scripts/build-linux-release.sh](/Users/wirabasalamah/Documents/Codex/abelbirdnest-web/deploy/scripts/build-linux-release.sh)
|
- [deploy/scripts/build-linux-release.sh](/Users/wirabasalamah/Documents/Codex/abelbirdnest-web/deploy/scripts/build-linux-release.sh)
|
||||||
- Karakteristik workflow ini:
|
- Karakteristik workflow ini:
|
||||||
- build di Docker `linux/amd64`
|
- build di Docker Alpine Linux agar cocok dengan server Alpine
|
||||||
- install `openssl` di container
|
- install `openssl` di container
|
||||||
- pakai `NODE_OPTIONS=--max-old-space-size=4096`
|
- pakai `NODE_OPTIONS=--max-old-space-size=4096`
|
||||||
- copy source ke `/work` agar tidak menimpa `node_modules` lokal Mac
|
- copy source ke `/work` agar tidak menimpa `node_modules` lokal Mac
|
||||||
- artifact final:
|
- artifact final memakai timestamp:
|
||||||
`abelbirdnest-release.tar.gz`
|
`abelbirdnest-release-YYYYMMDD-HHMMSS.tar.gz`
|
||||||
- Artifact final tidak membawa `.env.production` lokal.
|
- Artifact final tidak membawa `.env.production` lokal.
|
||||||
|
|
||||||
|
### Catatan Tambahan 2026-05-26
|
||||||
|
|
||||||
|
- Server production memakai Alpine Linux dengan RAM sekitar 1 GB.
|
||||||
|
- Jangan compile/build di server.
|
||||||
|
- Artifact harus dibuat dari container Alpine, default script sekarang memakai `node:20-alpine`.
|
||||||
|
- Runtime server Alpine perlu `nodejs` dan `openssl`.
|
||||||
|
- Ukuran artifact runtime-only yang normal sekitar `43 MB`.
|
||||||
|
- Jika artifact membengkak sekitar `120 MB+`, kemungkinan Prisma CLI atau `node_modules` lengkap ikut terbawa. Jangan pakai varian besar itu untuk server 1 GB kecuali memang sengaja.
|
||||||
|
- Artifact runtime-only tidak dipakai untuk menjalankan `prisma migrate deploy` di server. Untuk update UI/app tanpa perubahan schema DB, cukup extract release baru lalu restart service.
|
||||||
|
- Jika nanti ada migration database, jalankan migration dari mesin build/admin yang punya dependency lengkap dan akses DB production, bukan dari server kecil.
|
||||||
|
|
||||||
## Catatan Deploy Server Saat Ini
|
## Catatan Deploy Server Saat Ini
|
||||||
|
|
||||||
- Path app di server:
|
- Path app aktif di server production saat ini:
|
||||||
`/var/www/abelbirdnest-web/AbelBirdNest-Stock`
|
`/opt/abelbirdnest-web`
|
||||||
- Struktur release yang dipakai sekarang diasumsikan:
|
- Struktur release yang dipakai sekarang diasumsikan:
|
||||||
- upload artifact ke root repo server
|
- upload artifact ke root app server:
|
||||||
|
`/opt/abelbirdnest-web`
|
||||||
- extract ke `releases/<timestamp>`
|
- extract ke `releases/<timestamp>`
|
||||||
- symlink `current` diarahkan ke release aktif
|
- symlink `current` diarahkan ke release aktif
|
||||||
- `systemd` yang benar untuk mode artifact:
|
- Folder aktif runtime:
|
||||||
- `WorkingDirectory=/var/www/abelbirdnest-web/AbelBirdNest-Stock/current`
|
`/opt/abelbirdnest-web/current`
|
||||||
- `EnvironmentFile=/var/www/abelbirdnest-web/AbelBirdNest-Stock/.env.production`
|
- Service server memakai OpenRC, bukan systemd.
|
||||||
- `ExecStart=/usr/bin/node server.js`
|
- Service OpenRC production yang ditemukan:
|
||||||
|
- `directory="/opt/abelbirdnest-web/current"`
|
||||||
|
- `command="/usr/bin/node"`
|
||||||
|
- `command_args="/opt/abelbirdnest-web/current/server.js"`
|
||||||
|
- env yang dicek service:
|
||||||
|
`/opt/abelbirdnest-web/current/.env`
|
||||||
|
- Karena service mencari `.env` di folder `current`, setiap release baru perlu copy `.env` dari release/current lama ke folder release baru sebelum `current` diarahkan ke release baru.
|
||||||
|
- Jangan gunakan path lama `/var/www/abelbirdnest-web/AbelBirdNest-Stock` untuk server ini kecuali service production memang sudah diubah.
|
||||||
|
|
||||||
|
### Command Manual Deploy Yang Dipakai
|
||||||
|
|
||||||
|
Di lokal:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scp abelbirdnest-release-YYYYMMDD-HHMMSS.tar.gz abelbirdnest@SERVER_IP:/opt/abelbirdnest-web/
|
||||||
|
```
|
||||||
|
|
||||||
|
Di server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/abelbirdnest-web
|
||||||
|
RELEASE_NAME=YYYYMMDD-HHMMSS
|
||||||
|
mkdir -p /opt/abelbirdnest-web/releases/$RELEASE_NAME
|
||||||
|
tar -xzf /opt/abelbirdnest-web/abelbirdnest-release-$RELEASE_NAME.tar.gz -C /opt/abelbirdnest-web/releases/$RELEASE_NAME
|
||||||
|
cp /opt/abelbirdnest-web/current/.env /opt/abelbirdnest-web/releases/$RELEASE_NAME/.env
|
||||||
|
mv /opt/abelbirdnest-web/current /opt/abelbirdnest-web/current-backup-$(date +%Y%m%d-%H%M%S) 2>/dev/null || true
|
||||||
|
ln -sfn /opt/abelbirdnest-web/releases/$RELEASE_NAME /opt/abelbirdnest-web/current
|
||||||
|
sudo chown -R abelbirdnest:abelbirdnest /opt/abelbirdnest-web
|
||||||
|
sudo rc-service abelbirdnest-web restart
|
||||||
|
sudo rc-service abelbirdnest-web status
|
||||||
|
curl -I http://127.0.0.1:3007/login
|
||||||
|
```
|
||||||
|
|
||||||
|
Jika `ln -sfn ... current` gagal dengan pesan `File exists`, berarti `current` masih folder biasa. Backup/rename folder itu dulu, lalu ulangi `ln -sfn`.
|
||||||
|
|
||||||
## Verifikasi yang Sudah Dilakukan
|
## Verifikasi yang Sudah Dilakukan
|
||||||
|
|
||||||
|
|||||||
121
docs/deploy-alpine-update.md
Normal file
121
docs/deploy-alpine-update.md
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
# Update Deploy Alpine
|
||||||
|
|
||||||
|
Panduan ini untuk server production Alpine Linux x86_64 RAM 1 GB. Server tidak menjalankan build/compile.
|
||||||
|
|
||||||
|
## 1. Build Artifact Di Mesin Lokal/Build Machine
|
||||||
|
|
||||||
|
Mesin build harus punya Docker.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./deploy/scripts/build-linux-release.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Script memakai `node:20-alpine`, menjalankan `npm ci`, `prisma generate`, `next build`, lalu membuat artifact bertanggal:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
abelbirdnest-release-YYYYMMDD-HHMMSS.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Upload Artifact Ke Server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./deploy/scripts/upload-linux-release.sh abelbirdnest@SERVER_IP /opt/abelbirdnest-web
|
||||||
|
```
|
||||||
|
|
||||||
|
Script otomatis memilih artifact terbaru dengan pola `abelbirdnest-release-*.tar.gz`, upload artifact, extract ke:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/opt/abelbirdnest-web/releases/<timestamp>
|
||||||
|
```
|
||||||
|
|
||||||
|
Lalu update symlink:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/opt/abelbirdnest-web/current
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Prasyarat Server Alpine
|
||||||
|
|
||||||
|
Jalankan sekali di server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apk add --no-cache nodejs openssl
|
||||||
|
```
|
||||||
|
|
||||||
|
Pastikan env production ada di:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/var/www/abelbirdnest-web/AbelBirdNest-Stock/.env.production
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Setup Service OpenRC
|
||||||
|
|
||||||
|
Copy template service dari mesin lokal/build machine:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scp deploy/openrc/abelbirdnest-web abelbirdnest@SERVER_IP:/tmp/abelbirdnest-web
|
||||||
|
ssh abelbirdnest@SERVER_IP
|
||||||
|
sudo mv /tmp/abelbirdnest-web /etc/init.d/abelbirdnest-web
|
||||||
|
sudo chmod +x /etc/init.d/abelbirdnest-web
|
||||||
|
sudo rc-update add abelbirdnest-web default
|
||||||
|
```
|
||||||
|
|
||||||
|
Service menjalankan:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /var/www/abelbirdnest-web/AbelBirdNest-Stock/current
|
||||||
|
PORT=3007 NODE_ENV=production NODE_OPTIONS=--max-old-space-size=512 node server.js
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Setelah Upload
|
||||||
|
|
||||||
|
Masuk server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh abelbirdnest@SERVER_IP
|
||||||
|
cd /var/www/abelbirdnest-web/AbelBirdNest-Stock/current
|
||||||
|
```
|
||||||
|
|
||||||
|
Artifact release dibuat ramping untuk runtime Next.js standalone. Jangan menjalankan `npm install`, `npm run build`, atau `prisma migrate deploy` di server kecil.
|
||||||
|
|
||||||
|
Jika update berikutnya membawa migration database, jalankan migration dari mesin build/admin yang punya dependency lengkap dan akses ke database production. Untuk update UI/app tanpa migration, langsung restart service.
|
||||||
|
|
||||||
|
Load env hanya jika perlu tes manual:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
set -a
|
||||||
|
. /var/www/abelbirdnest-web/AbelBirdNest-Stock/.env.production
|
||||||
|
set +a
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart service:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo rc-service abelbirdnest-web restart
|
||||||
|
sudo rc-service abelbirdnest-web status
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. Verifikasi
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -I http://127.0.0.1:3007/login
|
||||||
|
tail -n 100 /var/log/abelbirdnest-web.err
|
||||||
|
tail -n 100 /var/log/abelbirdnest-web.log
|
||||||
|
```
|
||||||
|
|
||||||
|
Jika memakai reverse proxy, cek domain public setelah service sehat.
|
||||||
|
|
||||||
|
## 7. Rollback Cepat
|
||||||
|
|
||||||
|
Lihat release yang tersedia:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls -lt /var/www/abelbirdnest-web/AbelBirdNest-Stock/releases
|
||||||
|
```
|
||||||
|
|
||||||
|
Arahkan `current` ke release sebelumnya:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ln -sfn /var/www/abelbirdnest-web/AbelBirdNest-Stock/releases/NAMA_RELEASE_LAMA /var/www/abelbirdnest-web/AbelBirdNest-Stock/current
|
||||||
|
sudo rc-service abelbirdnest-web restart
|
||||||
|
```
|
||||||
@ -354,7 +354,7 @@ sudo systemctl restart abelbirdnest-web
|
|||||||
|
|
||||||
## 13. Alternatif Deploy: Build di Lokal, Jalankan di Server
|
## 13. Alternatif Deploy: Build di Lokal, Jalankan di Server
|
||||||
|
|
||||||
Repo ini sekarang mendukung output Next.js `standalone`, sehingga build bisa dilakukan di lingkungan Linux lokal/container lalu artifact di-upload ke server.
|
Repo ini sekarang mendukung output Next.js `standalone`, sehingga build dilakukan di lingkungan Linux lokal/container lalu artifact di-upload ke server. Server production tidak menjalankan `npm run build`.
|
||||||
|
|
||||||
### Kapan jalur ini dipakai
|
### Kapan jalur ini dipakai
|
||||||
|
|
||||||
@ -366,8 +366,10 @@ Pakai cara ini jika:
|
|||||||
|
|
||||||
### Prasyarat
|
### Prasyarat
|
||||||
|
|
||||||
- build harus dilakukan di Linux, atau container Linux
|
- build harus dilakukan di Linux Alpine, atau container Linux Alpine
|
||||||
- versi Node saat build harus sama dengan server
|
- versi Node saat build harus sama dengan server
|
||||||
|
- server Alpine perlu `nodejs` dan `openssl` terpasang
|
||||||
|
- untuk server RAM 1 GB, runtime disarankan memakai `NODE_OPTIONS=--max-old-space-size=512`
|
||||||
- database migration tetap dijalankan di server
|
- database migration tetap dijalankan di server
|
||||||
|
|
||||||
### Script yang disediakan
|
### Script yang disediakan
|
||||||
@ -386,16 +388,16 @@ deploy/scripts/upload-linux-release.sh
|
|||||||
|
|
||||||
### Contoh build artifact di lokal
|
### Contoh build artifact di lokal
|
||||||
|
|
||||||
Pastikan `.env.production` sudah ada di root repo jika ingin ikut dibawa ke artifact.
|
Pastikan Docker tersedia di mesin lokal/build machine. Script default memakai image `node:20-alpine` agar artifact cocok dengan server Alpine Linux. `.env.production` production tetap disimpan di server, bukan dibawa di artifact.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./deploy/scripts/build-linux-release.sh
|
./deploy/scripts/build-linux-release.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Artifact default yang dihasilkan:
|
Artifact default yang dihasilkan memakai timestamp:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
abelbirdnest-release.tar.gz
|
abelbirdnest-release-YYYYMMDD-HHMMSS.tar.gz
|
||||||
```
|
```
|
||||||
|
|
||||||
Isi artifact:
|
Isi artifact:
|
||||||
@ -404,19 +406,19 @@ Isi artifact:
|
|||||||
- static assets `.next/static`
|
- static assets `.next/static`
|
||||||
- folder `public`
|
- folder `public`
|
||||||
- folder `prisma`
|
- folder `prisma`
|
||||||
- `.env.production` jika tersedia
|
- tidak membawa `.env.production`
|
||||||
|
|
||||||
### Contoh upload ke server
|
### Contoh upload ke server
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./deploy/scripts/upload-linux-release.sh abelbirdnest@server /var/www/abelbirdnest-web
|
./deploy/scripts/upload-linux-release.sh abelbirdnest@server /var/www/abelbirdnest-web/AbelBirdNest-Stock
|
||||||
```
|
```
|
||||||
|
|
||||||
Setelah upload, script akan:
|
Setelah upload, script akan:
|
||||||
|
|
||||||
- copy artifact ke server
|
- copy artifact ke server
|
||||||
- extract ke `/var/www/abelbirdnest-web/releases/<timestamp>`
|
- extract ke `/var/www/abelbirdnest-web/AbelBirdNest-Stock/releases/<timestamp>`
|
||||||
- update symlink `/var/www/abelbirdnest-web/current`
|
- update symlink `/var/www/abelbirdnest-web/AbelBirdNest-Stock/current`
|
||||||
|
|
||||||
### Langkah setelah upload di server
|
### Langkah setelah upload di server
|
||||||
|
|
||||||
@ -424,13 +426,11 @@ Masuk ke server:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
ssh abelbirdnest@server
|
ssh abelbirdnest@server
|
||||||
cd /var/www/abelbirdnest-web/current
|
cd /var/www/abelbirdnest-web/AbelBirdNest-Stock/current
|
||||||
set -a
|
|
||||||
source .env.production
|
|
||||||
set +a
|
|
||||||
npx prisma migrate deploy
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Artifact standalone dibuat untuk runtime. Jangan menjalankan `npm install`, `npm run build`, atau `prisma migrate deploy` di server kecil. Jika rilis membawa migration database, jalankan migration dari mesin build/admin yang punya dependency lengkap dan akses ke database production.
|
||||||
|
|
||||||
Untuk menjalankan standalone app secara manual:
|
Untuk menjalankan standalone app secara manual:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@ -440,7 +440,7 @@ PORT=3007 node server.js
|
|||||||
Jika memakai `systemd`, `WorkingDirectory` service harus diarahkan ke:
|
Jika memakai `systemd`, `WorkingDirectory` service harus diarahkan ke:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
/var/www/abelbirdnest-web/current
|
/var/www/abelbirdnest-web/AbelBirdNest-Stock/current
|
||||||
```
|
```
|
||||||
|
|
||||||
dan `ExecStart` menjadi:
|
dan `ExecStart` menjadi:
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
binaryTargets = ["native", "darwin-arm64", "debian-openssl-3.0.x"]
|
binaryTargets = ["native", "darwin-arm64", "debian-openssl-3.0.x", "linux-musl-openssl-3.0.x"]
|
||||||
}
|
}
|
||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
|
|||||||
@ -19,9 +19,12 @@ import { prisma } from "@/lib/prisma";
|
|||||||
|
|
||||||
const loginSchema = z.object({
|
const loginSchema = z.object({
|
||||||
identity: z.string().trim().min(1, "Email atau username wajib diisi"),
|
identity: z.string().trim().min(1, "Email atau username wajib diisi"),
|
||||||
password: z.string().min(1, "Password wajib diisi")
|
password: z.string().min(1, "Password wajib diisi"),
|
||||||
|
remember_me: z.boolean().optional().default(false)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const REMEMBER_ME_TTL_SECONDS = 60 * 60 * 24 * 30;
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
await ensureAuthBootstrap();
|
await ensureAuthBootstrap();
|
||||||
@ -39,7 +42,7 @@ export async function POST(request: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { identity, password } = parsed.data;
|
const { identity, password, remember_me: rememberMe } = parsed.data;
|
||||||
const normalizedIdentity = identity.toLowerCase();
|
const normalizedIdentity = identity.toLowerCase();
|
||||||
const [mustVerifyEmail, sessionTtlSeconds] = await Promise.all([
|
const [mustVerifyEmail, sessionTtlSeconds] = await Promise.all([
|
||||||
isEmailVerificationRequired(),
|
isEmailVerificationRequired(),
|
||||||
@ -100,7 +103,10 @@ export async function POST(request: Request) {
|
|||||||
email: user.email,
|
email: user.email,
|
||||||
username: user.username
|
username: user.username
|
||||||
};
|
};
|
||||||
const sessionToken = createSessionToken(sessionUser, sessionTtlSeconds);
|
const effectiveSessionTtlSeconds = rememberMe
|
||||||
|
? REMEMBER_ME_TTL_SECONDS
|
||||||
|
: sessionTtlSeconds || getSessionTtlSeconds();
|
||||||
|
const sessionToken = createSessionToken(sessionUser, effectiveSessionTtlSeconds);
|
||||||
|
|
||||||
const response = NextResponse.json({
|
const response = NextResponse.json({
|
||||||
message: "Login berhasil",
|
message: "Login berhasil",
|
||||||
@ -109,14 +115,14 @@ export async function POST(request: Request) {
|
|||||||
redirect_to: getDefaultPathForRole(sessionUser.role),
|
redirect_to: getDefaultPathForRole(sessionUser.role),
|
||||||
session_token: sessionToken,
|
session_token: sessionToken,
|
||||||
token_type: "Bearer",
|
token_type: "Bearer",
|
||||||
expires_in: sessionTtlSeconds || getSessionTtlSeconds()
|
expires_in: effectiveSessionTtlSeconds
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
response.cookies.set(
|
response.cookies.set(
|
||||||
AUTH_COOKIE_NAME,
|
AUTH_COOKIE_NAME,
|
||||||
sessionToken,
|
sessionToken,
|
||||||
getSessionCookieOptions(sessionTtlSeconds)
|
getSessionCookieOptions(effectiveSessionTtlSeconds)
|
||||||
);
|
);
|
||||||
|
|
||||||
await createAuditTrailSafe({
|
await createAuditTrailSafe({
|
||||||
|
|||||||
@ -22,12 +22,12 @@ export default async function DashboardPage() {
|
|||||||
title="Dashboard Operasional"
|
title="Dashboard Operasional"
|
||||||
description="Status real-time lot persediaan, transaksi masuk, dan titik perhatian operasional gudang walet."
|
description="Status real-time lot persediaan, transaksi masuk, dan titik perhatian operasional gudang walet."
|
||||||
>
|
>
|
||||||
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
<section className="grid gap-6 md:grid-cols-2 xl:grid-cols-4">
|
||||||
{dashboard.metrics.map((metric) => (
|
{dashboard.metrics.map((metric) => (
|
||||||
<MetricCard key={metric.label} metric={metric} />
|
<MetricCard key={metric.label} metric={metric} />
|
||||||
))}
|
))}
|
||||||
</section>
|
</section>
|
||||||
<section className="grid gap-6 xl:grid-cols-[1.3fr_0.7fr]">
|
<section className="mt-6 grid gap-6 xl:grid-cols-[1.3fr_0.7fr]">
|
||||||
<article className="ops-card p-6">
|
<article className="ops-card p-6">
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
@ -80,7 +80,7 @@ export default async function DashboardPage() {
|
|||||||
recentActivity={dashboard.recentActivity}
|
recentActivity={dashboard.recentActivity}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
<section className="grid gap-6 xl:grid-cols-[1.25fr_0.75fr]">
|
<section className="mt-6 grid gap-6 xl:grid-cols-[1.25fr_0.75fr]">
|
||||||
<LotTable lots={dashboard.criticalLots} />
|
<LotTable lots={dashboard.criticalLots} />
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<article className="ops-card p-5">
|
<article className="ops-card p-5">
|
||||||
|
|||||||
@ -3,18 +3,21 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Eye, EyeOff, Lock, LogIn, User } from "lucide-react";
|
import { Eye, EyeOff, Lock, LogIn, User } from "lucide-react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { FormEvent, useState } from "react";
|
import { FormEvent, useEffect, useState } from "react";
|
||||||
|
|
||||||
import { AppLogo } from "@/components/branding/app-logo";
|
import { AppLogo } from "@/components/branding/app-logo";
|
||||||
import { LanguageSwitcher } from "@/components/layout/language-switcher";
|
import { LanguageSwitcher } from "@/components/layout/language-switcher";
|
||||||
import { useLocale } from "@/components/providers/locale-provider";
|
import { useLocale } from "@/components/providers/locale-provider";
|
||||||
|
|
||||||
|
const REMEMBERED_IDENTITY_KEY = "abelbirdnest_remembered_identity";
|
||||||
|
|
||||||
export function LoginClient() {
|
export function LoginClient() {
|
||||||
const { dict } = useLocale();
|
const { dict } = useLocale();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const [identity, setIdentity] = useState("");
|
const [identity, setIdentity] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
|
const [rememberDevice, setRememberDevice] = useState(true);
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@ -22,6 +25,15 @@ export function LoginClient() {
|
|||||||
const [helperSubmitting, setHelperSubmitting] = useState<"reset" | "verify" | null>(null);
|
const [helperSubmitting, setHelperSubmitting] = useState<"reset" | "verify" | null>(null);
|
||||||
const [helperMessage, setHelperMessage] = useState<string | null>(null);
|
const [helperMessage, setHelperMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const rememberedIdentity = window.localStorage.getItem(REMEMBERED_IDENTITY_KEY);
|
||||||
|
|
||||||
|
if (rememberedIdentity) {
|
||||||
|
setIdentity(rememberedIdentity);
|
||||||
|
setRememberDevice(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
async function submitHelper(type: "reset" | "verify") {
|
async function submitHelper(type: "reset" | "verify") {
|
||||||
setHelperSubmitting(type);
|
setHelperSubmitting(type);
|
||||||
setHelperMessage(null);
|
setHelperMessage(null);
|
||||||
@ -68,7 +80,8 @@ export function LoginClient() {
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
identity,
|
identity,
|
||||||
password
|
password,
|
||||||
|
remember_me: rememberDevice
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -87,6 +100,12 @@ export function LoginClient() {
|
|||||||
throw new Error(firstError ?? payload.message ?? dict.common.requestFailed);
|
throw new Error(firstError ?? payload.message ?? dict.common.requestFailed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (rememberDevice) {
|
||||||
|
window.localStorage.setItem(REMEMBERED_IDENTITY_KEY, identity.trim());
|
||||||
|
} else {
|
||||||
|
window.localStorage.removeItem(REMEMBERED_IDENTITY_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
const next = searchParams.get("next") || payload.data?.redirect_to || "/dashboard";
|
const next = searchParams.get("next") || payload.data?.redirect_to || "/dashboard";
|
||||||
router.push(next);
|
router.push(next);
|
||||||
router.refresh();
|
router.refresh();
|
||||||
@ -149,6 +168,7 @@ export function LoginClient() {
|
|||||||
onChange={(event) => setIdentity(event.target.value)}
|
onChange={(event) => setIdentity(event.target.value)}
|
||||||
className="ops-input pl-11"
|
className="ops-input pl-11"
|
||||||
placeholder="name@company.com"
|
placeholder="name@company.com"
|
||||||
|
autoComplete="username"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
@ -166,6 +186,7 @@ export function LoginClient() {
|
|||||||
onChange={(event) => setPassword(event.target.value)}
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
className="ops-input pl-11 pr-12"
|
className="ops-input pl-11 pr-12"
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
|
autoComplete="current-password"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -178,7 +199,12 @@ export function LoginClient() {
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="flex items-center gap-3 text-[15px] text-slate-600">
|
<label className="flex items-center gap-3 text-[15px] text-slate-600">
|
||||||
<input type="checkbox" className="h-4 w-4 rounded border-line/70 text-moss focus:ring-moss" defaultChecked />
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4 rounded border-line/70 text-moss focus:ring-moss"
|
||||||
|
checked={rememberDevice}
|
||||||
|
onChange={(event) => setRememberDevice(event.target.checked)}
|
||||||
|
/>
|
||||||
<span>{dict.login.remember}</span>
|
<span>{dict.login.remember}</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user