Skip to content

Commit b904f33

Browse files
author
Ravi Singh
committed
feat: add CI/CD deploy workflow and PostgreSQL cloud server
- GitHub Actions deploys to DO droplet on push to pwa/ - server-cloud/ contains PostgreSQL-based server (replaces SQLite for cloud) - package-cloud.json uses pg driver instead of better-sqlite3 - .env.example updated with Resend, Turnstile, MQTT public host vars
1 parent 7640e39 commit b904f33

9 files changed

Lines changed: 1296 additions & 2 deletions

File tree

.github/workflows/deploy-cloud.yml

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
name: Deploy TankSync Cloud
2+
3+
on:
4+
push:
5+
branches: [main]
6+
paths:
7+
- 'pwa/**'
8+
9+
concurrency:
10+
group: deploy-production
11+
cancel-in-progress: false
12+
13+
jobs:
14+
deploy:
15+
runs-on: ubuntu-latest
16+
env:
17+
DROPLET_IP: "168.144.32.179"
18+
APP_DIR: "/home/tanksync/tanksync-pwa"
19+
VITE_TURNSTILE_SITE_KEY: ${{ secrets.VITE_TURNSTILE_SITE_KEY }}
20+
21+
steps:
22+
- uses: actions/checkout@v4
23+
24+
- uses: actions/setup-node@v4
25+
with:
26+
node-version: 22
27+
cache: npm
28+
cache-dependency-path: pwa/package-cloud.json
29+
30+
- name: Install dependencies
31+
working-directory: pwa
32+
run: |
33+
cp package-cloud.json package.json
34+
npm install
35+
36+
- name: Build frontend
37+
working-directory: pwa
38+
run: npm run build
39+
40+
- name: Setup SSH
41+
run: |
42+
mkdir -p ~/.ssh
43+
echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/deploy_key
44+
chmod 600 ~/.ssh/deploy_key
45+
ssh-keyscan -H ${{ env.DROPLET_IP }} >> ~/.ssh/known_hosts
46+
47+
- name: Deploy to droplet
48+
run: |
49+
SSH="ssh -i ~/.ssh/deploy_key root@${{ env.DROPLET_IP }}"
50+
RSYNC="rsync -avz --delete -e 'ssh -i ~/.ssh/deploy_key'"
51+
52+
# Sync frontend build
53+
eval $RSYNC pwa/dist/ root@${{ env.DROPLET_IP }}:${{ env.APP_DIR }}/dist/
54+
55+
# Sync cloud server code
56+
eval $RSYNC --exclude node_modules \
57+
pwa/server-cloud/ root@${{ env.DROPLET_IP }}:${{ env.APP_DIR }}/server/
58+
59+
# Sync package files
60+
eval rsync -avz -e "'ssh -i ~/.ssh/deploy_key'" \
61+
pwa/package-cloud.json root@${{ env.DROPLET_IP }}:${{ env.APP_DIR }}/package.json
62+
63+
# Sync client source (for future builds on server if needed)
64+
eval $RSYNC --exclude node_modules \
65+
pwa/client/ root@${{ env.DROPLET_IP }}:${{ env.APP_DIR }}/client/
66+
67+
# Install deps and restart
68+
$SSH << 'ENDSSH'
69+
cd /home/tanksync/tanksync-pwa
70+
chown -R tanksync:tanksync .
71+
sudo -u tanksync npm ci --omit=dev 2>&1 | tail -3
72+
systemctl restart tanksync
73+
sleep 2
74+
systemctl is-active tanksync && echo "Deploy successful" || echo "DEPLOY FAILED"
75+
ENDSSH

pwa/.env.example

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# TankSync PWA — Environment Variables
1+
# TankSync Cloud — Environment Variables
22
# Copy this file to .env and customize for your deployment
33

44
# Server
@@ -7,9 +7,17 @@ NODE_ENV=production
77
JWT_SECRET=change-this-to-a-random-64-char-string
88

99
# MQTT Broker
10-
# Default: connects to localhost:1883 (Mosquitto on same machine)
1110
MQTT_URL=mqtt://localhost:1883
1211

12+
# Email Verification (Resend.com — free 100 emails/day)
13+
# Get API key from https://resend.com
14+
RESEND_API_KEY=
15+
EMAIL_FROM=TankSync <noreply@yourdomain.com>
16+
17+
# Cloudflare Turnstile (anti-bot on signup)
18+
# Get keys from https://dash.cloudflare.com/?to=/:account/turnstile
19+
TURNSTILE_SECRET=
20+
1321
# Web Push Notifications (optional)
1422
# Generate VAPID keys: npx web-push generate-vapid-keys
1523
VAPID_PUBLIC_KEY=

pwa/package-cloud.json

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
{
2+
"name": "tanksync-cloud",
3+
"version": "1.0.0",
4+
"private": true,
5+
"type": "module",
6+
"scripts": {
7+
"start": "NODE_ENV=production node server/index.js",
8+
"build": "vite build --config client/vite.config.js"
9+
},
10+
"dependencies": {
11+
"@fastify/cors": "^10.0.2",
12+
"@fastify/jwt": "^10.0.0",
13+
"@fastify/rate-limit": "^10.3.0",
14+
"@fastify/static": "^8.0.0",
15+
"bcryptjs": "^2.4.3",
16+
"pg": "^8.13.1",
17+
"fastify": "^5.2.1",
18+
"jsqr": "^1.4.0",
19+
"mqtt": "^5.10.4",
20+
"web-push": "^3.6.7"
21+
},
22+
"devDependencies": {
23+
"@tailwindcss/vite": "^4.1.4",
24+
"@vitejs/plugin-react": "^4.3.4",
25+
"autoprefixer": "^10.4.20",
26+
"concurrently": "^9.1.2",
27+
"react": "^19.0.0",
28+
"react-dom": "^19.0.0",
29+
"react-router-dom": "^7.1.3",
30+
"tailwindcss": "^4.1.4",
31+
"vite": "^6.1.0",
32+
"vite-plugin-pwa": "^0.21.1"
33+
}
34+
}

pwa/server-cloud/alerts.js

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
// Copyright (c) 2025-2026 Ravi Singh (Techposts)
3+
4+
import db, { stmts } from './db.js';
5+
import { broadcastToUser } from './sse.js';
6+
import { sendPush } from './push-sender.js';
7+
8+
const alertState = new Map();
9+
const VAPID_PUBLIC = process.env.VAPID_PUBLIC_KEY || '';
10+
11+
function getState(deviceId) {
12+
if (!alertState.has(deviceId)) {
13+
alertState.set(deviceId, {
14+
low_water: false, tank_empty: false, high_water: false,
15+
low_battery: false, battery_critical: false, weak_signal: false,
16+
device_offline: false, device_stale: false, rapid_drop: false,
17+
recentReadings: [], lastOnlineTime: null, wasOffline: false,
18+
});
19+
}
20+
return alertState.get(deviceId);
21+
}
22+
23+
export async function checkAlerts(device, site) {
24+
const state = getState(device.id);
25+
const pct = device.last_water_pct;
26+
const bat = device.last_battery_pct;
27+
const rssi = device.last_rssi;
28+
const now = Date.now();
29+
30+
if (pct != null) {
31+
state.recentReadings.push({ pct, timestamp: now });
32+
const twoHoursAgo = now - 2 * 3600_000;
33+
state.recentReadings = state.recentReadings.filter(r => r.timestamp > twoHoursAgo);
34+
}
35+
36+
// Tank empty
37+
if (pct != null && pct <= 0 && !state.tank_empty) {
38+
state.tank_empty = true;
39+
await createAlert(device, site, 'tank_empty', `${device.name} is completely empty! 0% water remaining.`, 'critical');
40+
} else if (pct > 5) { state.tank_empty = false; }
41+
42+
// Low water
43+
if (pct != null && pct > 0 && pct <= device.alert_low_pct && !state.low_water) {
44+
state.low_water = true;
45+
await createAlert(device, site, 'low_water', `${device.name} water level low: ${Math.round(pct)}% (threshold: ${device.alert_low_pct}%)`, 'critical');
46+
} else if (pct > device.alert_low_pct + 5) {
47+
if (state.low_water) {
48+
state.low_water = false;
49+
await createAlert(device, site, 'level_recovered', `${device.name} water level recovered to ${Math.round(pct)}%`, 'info');
50+
}
51+
}
52+
53+
// High water
54+
if (pct != null && pct >= device.alert_high_pct && !state.high_water) {
55+
state.high_water = true;
56+
await createAlert(device, site, 'high_water', `${device.name} water level high: ${Math.round(pct)}% — possible overflow risk`, 'warning');
57+
} else if (pct < device.alert_high_pct - 5) { state.high_water = false; }
58+
59+
// Rapid drop
60+
if (state.recentReadings.length >= 3) {
61+
const oneHourAgo = now - 3600_000;
62+
const hourReadings = state.recentReadings.filter(r => r.timestamp > oneHourAgo);
63+
if (hourReadings.length >= 2) {
64+
const drop = hourReadings[0].pct - hourReadings[hourReadings.length - 1].pct;
65+
if (drop >= 20 && !state.rapid_drop) {
66+
state.rapid_drop = true;
67+
await createAlert(device, site, 'rapid_drop', `${device.name} dropped ${Math.round(drop)}% in the last hour — possible leak`, 'warning');
68+
} else if (drop < 10) { state.rapid_drop = false; }
69+
}
70+
}
71+
72+
// Rapid rise
73+
if (state.recentReadings.length >= 2) {
74+
const thirtyMinAgo = now - 1800_000;
75+
const recentR = state.recentReadings.filter(r => r.timestamp > thirtyMinAgo);
76+
if (recentR.length >= 2) {
77+
const rise = recentR[recentR.length - 1].pct - recentR[0].pct;
78+
if (rise >= 30) {
79+
await createAlert(device, site, 'rapid_rise', `${device.name} filling rapidly: +${Math.round(rise)}% in ${Math.round((now - recentR[0].timestamp) / 60000)}min`, 'info');
80+
state.recentReadings = recentR.slice(-2);
81+
}
82+
}
83+
}
84+
85+
// Battery critical
86+
if (bat != null && bat <= 5 && !state.battery_critical) {
87+
state.battery_critical = true;
88+
await createAlert(device, site, 'battery_critical', `${device.name} battery critically low: ${Math.round(bat)}%`, 'critical');
89+
} else if (bat > 15) { state.battery_critical = false; }
90+
91+
// Low battery
92+
if (bat != null && bat > 5 && bat <= 15 && !state.low_battery) {
93+
state.low_battery = true;
94+
await createAlert(device, site, 'low_battery', `${device.name} battery low: ${Math.round(bat)}%`, 'warning');
95+
} else if (bat > 25) {
96+
if (state.low_battery) {
97+
state.low_battery = false;
98+
await createAlert(device, site, 'battery_ok', `${device.name} battery recovered to ${Math.round(bat)}%`, 'info');
99+
}
100+
}
101+
102+
// Weak signal
103+
if (rssi != null && rssi < -100 && !state.weak_signal) {
104+
state.weak_signal = true;
105+
await createAlert(device, site, 'weak_signal', `${device.name} LoRa signal very weak: ${rssi} dBm`, 'warning');
106+
} else if (rssi != null && rssi > -90) { state.weak_signal = false; }
107+
108+
// Online recovery
109+
if (state.wasOffline && device.state === 'online') {
110+
state.wasOffline = false;
111+
state.device_offline = false;
112+
state.device_stale = false;
113+
await createAlert(device, site, 'device_online', `${device.name} is back online`, 'info');
114+
}
115+
116+
state.lastOnlineTime = now;
117+
}
118+
119+
export async function checkDeviceTimeouts() {
120+
const allDevices = await stmts.getAllDevicesWithSites();
121+
const now = Date.now();
122+
123+
for (const device of allDevices) {
124+
if (!device.last_seen) continue;
125+
const state = getState(device.id);
126+
const lastSeen = new Date(device.last_seen).getTime();
127+
const ageSec = (now - lastSeen) / 1000;
128+
const site = { id: device.site_id, name: device.site_name, user_id: device.user_id };
129+
130+
if (ageSec > 600 && ageSec <= 900 && !state.device_stale) {
131+
state.device_stale = true;
132+
await db.run('UPDATE devices SET state = $1 WHERE id = $2', 'stale', device.id);
133+
await createAlert(device, site, 'device_stale', `${device.name} hasn't reported in ${Math.round(ageSec / 60)} minutes`, 'info');
134+
}
135+
136+
if (ageSec > 900 && !state.device_offline) {
137+
state.device_offline = true;
138+
state.wasOffline = true;
139+
await db.run('UPDATE devices SET state = $1 WHERE id = $2', 'offline', device.id);
140+
await createAlert(device, site, 'device_offline', `${device.name} is offline — no data for ${Math.round(ageSec / 60)} minutes`, 'critical');
141+
}
142+
}
143+
}
144+
145+
const ESCALATION = {
146+
critical: [0, 60, 240, 720, 1440],
147+
warning: [0, 240, 1440],
148+
info: [0],
149+
};
150+
151+
async function createAlert(device, site, type, message, severity = 'info') {
152+
const intervals = ESCALATION[severity] || ESCALATION.info;
153+
154+
const lastAcked = await db.get(
155+
'SELECT id FROM alerts WHERE device_id = $1 AND type = $2 AND acknowledged = 1 ORDER BY created_at DESC LIMIT 1',
156+
device.id, type
157+
);
158+
159+
if (lastAcked) {
160+
const unackedSinceLast = await db.get(
161+
'SELECT id FROM alerts WHERE device_id = $1 AND type = $2 AND acknowledged = 0 AND created_at > (SELECT created_at FROM alerts WHERE id = $3)',
162+
device.id, type, lastAcked.id
163+
);
164+
if (unackedSinceLast) {
165+
const step = (await db.get(
166+
'SELECT COUNT(*) as c FROM alerts WHERE device_id = $1 AND type = $2 AND acknowledged = 0 AND created_at > (SELECT created_at FROM alerts WHERE id = $3)',
167+
device.id, type, lastAcked.id
168+
)).c;
169+
if (step >= intervals.length) return;
170+
const nextInterval = intervals[Math.min(step, intervals.length - 1)];
171+
const tooSoon = await db.get(
172+
`SELECT id FROM alerts WHERE device_id = $1 AND type = $2 AND created_at > NOW() - ($3 || ' minutes')::interval`,
173+
device.id, type, nextInterval || 1440
174+
);
175+
if (tooSoon) return;
176+
}
177+
} else {
178+
const lastAlert = await db.get(
179+
'SELECT id, created_at FROM alerts WHERE device_id = $1 AND type = $2 ORDER BY created_at DESC LIMIT 1',
180+
device.id, type
181+
);
182+
if (lastAlert) {
183+
const step = (await db.get(
184+
'SELECT COUNT(*) as c FROM alerts WHERE device_id = $1 AND type = $2 AND acknowledged = 0',
185+
device.id, type
186+
)).c;
187+
if (step >= intervals.length) {
188+
const tooSoon = await db.get(
189+
`SELECT id FROM alerts WHERE device_id = $1 AND type = $2 AND created_at > NOW() - interval '1440 minutes'`,
190+
device.id, type
191+
);
192+
if (tooSoon) return;
193+
} else {
194+
const nextInterval = intervals[Math.min(step, intervals.length - 1)];
195+
const tooSoon = await db.get(
196+
`SELECT id FROM alerts WHERE device_id = $1 AND type = $2 AND created_at > NOW() - ($3 || ' minutes')::interval`,
197+
device.id, type, Math.max(nextInterval, 1)
198+
);
199+
if (tooSoon) return;
200+
}
201+
}
202+
}
203+
204+
await stmts.insertAlert(device.id, type, message);
205+
206+
broadcastToUser(site.user_id, {
207+
type: 'alert',
208+
alert: { device_name: device.name, site_name: site.name, type, message, severity }
209+
});
210+
211+
if ((severity === 'critical' || severity === 'warning') && VAPID_PUBLIC) {
212+
const subs = await stmts.getPushSubs(device.id);
213+
if (subs.length === 0) return;
214+
const payload = JSON.stringify({
215+
title: severity === 'critical' ? '\u{1F6A8} TankSync Alert' : '\u{26A0}\u{FE0F} TankSync Warning',
216+
body: message, icon: '/icon-192.png', badge: '/icon-192.png',
217+
tag: `${type}-${device.id}`, renotify: severity === 'critical',
218+
data: { type, deviceId: device.id, severity, url: `/tank/${device.id}` }
219+
});
220+
for (const sub of subs) {
221+
sendPush(sub, payload).then(() => {
222+
console.log(`[Push] Sent to sub ${sub.id} (${type})`);
223+
}).catch(async (err) => {
224+
if ([403, 404, 410].includes(err.statusCode)) {
225+
await db.run('DELETE FROM push_subs WHERE id = $1', sub.id);
226+
} else {
227+
console.error(`[Push] Failed sub ${sub.id}:`, err.statusCode || err.message);
228+
}
229+
});
230+
}
231+
}
232+
}
233+
234+
export { checkDeviceTimeouts as runTimeoutChecks };

0 commit comments

Comments
 (0)