Tons of Stuff
This commit is contained in:
49
.gitea/workflows/README.md
Normal file
49
.gitea/workflows/README.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# Gitea Actions - Release Workflow
|
||||
|
||||
This workflow automatically builds and releases rmtPocketWatcher for Windows and Linux when you push a version tag.
|
||||
|
||||
## How to Trigger a Release
|
||||
|
||||
1. Update version in `electron-app/package.json`:
|
||||
```bash
|
||||
cd electron-app
|
||||
npm version patch # or minor, or major
|
||||
```
|
||||
|
||||
2. Push the tag to Gitea:
|
||||
```bash
|
||||
git push origin main
|
||||
git push origin --tags
|
||||
```
|
||||
|
||||
3. The workflow will automatically:
|
||||
- Build Windows installer (.exe)
|
||||
- Build Linux AppImage and .deb package
|
||||
- Create a GitHub/Gitea release
|
||||
- Upload all binaries to the release
|
||||
|
||||
## Requirements
|
||||
|
||||
- Gitea Actions must be enabled on your repository
|
||||
- Runners must be configured for `windows-latest` and `ubuntu-latest`
|
||||
- Repository must have write permissions for releases
|
||||
|
||||
## Manual Build
|
||||
|
||||
To build locally without releasing:
|
||||
|
||||
```bash
|
||||
cd electron-app
|
||||
npm run electron:build -- --win # Windows
|
||||
npm run electron:build -- --linux # Linux
|
||||
```
|
||||
|
||||
Outputs will be in `electron-app/release/`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If the workflow fails:
|
||||
- Check that Node.js 20 is available on runners
|
||||
- Verify all dependencies install correctly
|
||||
- Check Gitea Actions logs for specific errors
|
||||
- Ensure GITHUB_TOKEN has proper permissions
|
||||
86
.gitea/workflows/deploy.yml
Normal file
86
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,86 @@
|
||||
name: Deploy Backend to Docker
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'backend/**'
|
||||
- 'docker-compose.yml'
|
||||
- '.gitea/workflows/deploy.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build backend image
|
||||
working-directory: backend
|
||||
run: docker build -t rmtpocketwatcher-backend:latest .
|
||||
|
||||
- name: Save image to tar
|
||||
run: docker save rmtpocketwatcher-backend:latest -o backend-image.tar
|
||||
|
||||
- name: Deploy via SSH to Docker host
|
||||
env:
|
||||
DOCKER_HOST: ${{ secrets.DOCKER_HOST }}
|
||||
DOCKER_SSH_KEY: ${{ secrets.DOCKER_SSH_KEY }}
|
||||
DOCKER_USER: ${{ secrets.DOCKER_USER }}
|
||||
run: |
|
||||
# Setup SSH
|
||||
mkdir -p ~/.ssh
|
||||
echo "$DOCKER_SSH_KEY" > ~/.ssh/deploy_key
|
||||
chmod 600 ~/.ssh/deploy_key
|
||||
|
||||
# Copy image to remote host
|
||||
scp -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no \
|
||||
backend-image.tar ${DOCKER_USER}@${DOCKER_HOST}:/tmp/
|
||||
|
||||
# Copy docker-compose to remote host
|
||||
scp -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no \
|
||||
docker-compose.yml ${DOCKER_USER}@${DOCKER_HOST}:/opt/rmtpocketwatcher/
|
||||
|
||||
# Load image and restart services
|
||||
ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no \
|
||||
${DOCKER_USER}@${DOCKER_HOST} << 'EOF'
|
||||
cd /opt/rmtpocketwatcher
|
||||
docker load -i /tmp/backend-image.tar
|
||||
docker-compose down backend
|
||||
docker-compose up -d backend
|
||||
rm /tmp/backend-image.tar
|
||||
EOF
|
||||
|
||||
deploy-portainer:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Deploy via Portainer Webhook
|
||||
env:
|
||||
PORTAINER_WEBHOOK_URL: ${{ secrets.PORTAINER_WEBHOOK_URL }}
|
||||
run: |
|
||||
curl -X POST "$PORTAINER_WEBHOOK_URL"
|
||||
|
||||
- name: Deploy via Portainer API
|
||||
env:
|
||||
PORTAINER_URL: ${{ secrets.PORTAINER_URL }}
|
||||
PORTAINER_API_KEY: ${{ secrets.PORTAINER_API_KEY }}
|
||||
PORTAINER_STACK_ID: ${{ secrets.PORTAINER_STACK_ID }}
|
||||
run: |
|
||||
# Pull latest from git and redeploy stack
|
||||
curl -X PUT \
|
||||
"${PORTAINER_URL}/api/stacks/${PORTAINER_STACK_ID}/git/redeploy" \
|
||||
-H "X-API-Key: ${PORTAINER_API_KEY}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"pullImage": true,
|
||||
"prune": true
|
||||
}'
|
||||
96
.gitea/workflows/release.yml
Normal file
96
.gitea/workflows/release.yml
Normal file
@@ -0,0 +1,96 @@
|
||||
name: Build and Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
build-windows:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install electron-app dependencies
|
||||
working-directory: electron-app
|
||||
run: npm ci
|
||||
|
||||
- name: Build and package for Windows
|
||||
working-directory: electron-app
|
||||
run: npm run electron:build -- --win
|
||||
|
||||
- name: Upload Windows artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows-build
|
||||
path: electron-app/release/*.exe
|
||||
retention-days: 7
|
||||
|
||||
build-linux:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install electron-app dependencies
|
||||
working-directory: electron-app
|
||||
run: npm ci
|
||||
|
||||
- name: Build and package for Linux
|
||||
working-directory: electron-app
|
||||
run: npm run electron:build -- --linux
|
||||
|
||||
- name: Upload Linux AppImage
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-appimage
|
||||
path: electron-app/release/*.AppImage
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload Linux deb
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-deb
|
||||
path: electron-app/release/*.deb
|
||||
retention-days: 7
|
||||
|
||||
create-release:
|
||||
needs: [build-windows, build-linux]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: ./artifacts
|
||||
|
||||
- name: Display structure of downloaded files
|
||||
run: ls -R ./artifacts
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
files: |
|
||||
artifacts/windows-build/*.exe
|
||||
artifacts/linux-appimage/*.AppImage
|
||||
artifacts/linux-deb/*.deb
|
||||
draft: false
|
||||
prerelease: false
|
||||
generate_release_notes: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -4,23 +4,44 @@ import { PriceRepository } from '../../database/repository';
|
||||
const repository = new PriceRepository();
|
||||
|
||||
export const priceRoutes: FastifyPluginAsync = async (server) => {
|
||||
// GET /api/prices/latest - Get all current listings
|
||||
// GET /api/prices/latest - Get all current listings with lowest price
|
||||
server.get('/latest', async (request, reply) => {
|
||||
try {
|
||||
const prices = await repository.getLatestPrices();
|
||||
|
||||
if (prices.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
timestamp: new Date(),
|
||||
lowestPrice: 0,
|
||||
platform: '',
|
||||
sellerName: '',
|
||||
allPrices: []
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const lowestPrice = prices.reduce((min: any, p: any) =>
|
||||
Number(p.usdPerMillion) < Number(min.usdPerMillion) ? p : min
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: prices.map((p: any) => ({
|
||||
id: p.id,
|
||||
timestamp: p.timestamp,
|
||||
vendor: p.vendor,
|
||||
seller: p.sellerName,
|
||||
usdPrice: p.usdPrice.toString(),
|
||||
auecAmount: p.auecAmount.toString(),
|
||||
usdPerMillion: p.usdPerMillion.toString(),
|
||||
deliveryTime: p.deliveryTime,
|
||||
url: p.url,
|
||||
})),
|
||||
data: {
|
||||
timestamp: new Date(),
|
||||
lowestPrice: Number(lowestPrice.usdPerMillion),
|
||||
platform: lowestPrice.vendor,
|
||||
sellerName: lowestPrice.sellerName,
|
||||
allPrices: prices.map((p: any) => ({
|
||||
id: p.id,
|
||||
platform: p.vendor,
|
||||
sellerName: p.sellerName,
|
||||
pricePerMillion: Number(p.usdPerMillion),
|
||||
timestamp: p.timestamp,
|
||||
url: p.url
|
||||
}))
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
reply.code(500);
|
||||
|
||||
147
deploy/README.md
Normal file
147
deploy/README.md
Normal file
@@ -0,0 +1,147 @@
|
||||
# Deployment Guide
|
||||
|
||||
Multiple deployment methods for rmtPocketWatcher backend.
|
||||
|
||||
## Method 1: Portainer with Git Repository
|
||||
|
||||
**Best for:** Teams using Portainer for container management
|
||||
|
||||
### Setup in Portainer UI:
|
||||
|
||||
1. Go to Stacks → Add Stack → Git Repository
|
||||
2. Configure:
|
||||
- **Name**: rmtpocketwatcher
|
||||
- **Repository URL**: Your Git repo URL
|
||||
- **Reference**: refs/heads/main
|
||||
- **Compose path**: deploy/portainer-stack.yml
|
||||
3. Add environment variables:
|
||||
- `POSTGRES_PASSWORD`: your_secure_password
|
||||
- `IMAGE_TAG`: latest
|
||||
4. Deploy
|
||||
|
||||
### Update via Webhook:
|
||||
|
||||
1. In Portainer, enable webhook for the stack
|
||||
2. Add webhook URL to Gitea secrets: `PORTAINER_WEBHOOK_URL`
|
||||
3. Push to main branch triggers auto-deploy
|
||||
|
||||
### Update via API Script:
|
||||
|
||||
```bash
|
||||
export PORTAINER_URL="http://your-portainer:9000"
|
||||
export PORTAINER_API_KEY="your_api_key"
|
||||
export POSTGRES_PASSWORD="your_password"
|
||||
export GIT_REPO_URL="https://your-git-repo.git"
|
||||
|
||||
chmod +x deploy/portainer-deploy.sh
|
||||
./deploy/portainer-deploy.sh
|
||||
```
|
||||
|
||||
## Method 2: Direct Docker via SSH
|
||||
|
||||
**Best for:** Simple deployments to a single Docker host
|
||||
|
||||
```bash
|
||||
export DOCKER_HOST="your-server.com"
|
||||
export DOCKER_USER="deploy"
|
||||
export GIT_REPO="https://your-git-repo.git"
|
||||
|
||||
chmod +x deploy/docker-deploy.sh
|
||||
./deploy/docker-deploy.sh
|
||||
```
|
||||
|
||||
For local deployment:
|
||||
```bash
|
||||
export DOCKER_HOST="localhost"
|
||||
export GIT_REPO="https://your-git-repo.git"
|
||||
./deploy/docker-deploy.sh
|
||||
```
|
||||
|
||||
## Method 3: Gitea Actions (Automated)
|
||||
|
||||
**Best for:** CI/CD pipeline automation
|
||||
|
||||
### Setup:
|
||||
|
||||
1. Add secrets to your Gitea repository:
|
||||
- `DOCKER_HOST`: your-server.com
|
||||
- `DOCKER_USER`: deploy
|
||||
- `DOCKER_SSH_KEY`: Your SSH private key
|
||||
|
||||
2. Push to main branch or modify backend files
|
||||
3. Workflow automatically builds and deploys
|
||||
|
||||
### Manual trigger:
|
||||
- Go to Actions → Deploy Backend to Docker → Run workflow
|
||||
|
||||
## Method 4: Portainer API via Gitea Actions
|
||||
|
||||
**Best for:** Portainer users with CI/CD
|
||||
|
||||
### Setup:
|
||||
|
||||
1. Get Portainer API key:
|
||||
- Portainer → User Settings → Access tokens → Add access token
|
||||
|
||||
2. Add secrets to Gitea:
|
||||
- `PORTAINER_URL`: http://your-portainer:9000
|
||||
- `PORTAINER_API_KEY`: your_api_key
|
||||
- `PORTAINER_STACK_ID`: stack_id (from Portainer URL)
|
||||
- `PORTAINER_WEBHOOK_URL`: webhook URL (optional)
|
||||
|
||||
3. Manually trigger workflow or use webhook
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Required for all methods:
|
||||
- `POSTGRES_PASSWORD`: Database password
|
||||
|
||||
Optional:
|
||||
- `POSTGRES_USER`: Database user (default: rmtpw)
|
||||
- `POSTGRES_DB`: Database name (default: rmtpocketwatcher)
|
||||
- `BACKEND_PORT`: Backend port (default: 3000)
|
||||
- `SCRAPE_INTERVAL`: Minutes between scrapes (default: 5)
|
||||
- `SCRAPER_TIMEOUT`: Scraper timeout in ms (default: 30000)
|
||||
- `SCRAPER_MAX_RETRIES`: Max retry attempts (default: 3)
|
||||
|
||||
## Monitoring
|
||||
|
||||
Check deployment status:
|
||||
```bash
|
||||
docker-compose ps
|
||||
docker-compose logs -f backend
|
||||
```
|
||||
|
||||
Via Portainer:
|
||||
- Stacks → rmtpocketwatcher → Container logs
|
||||
|
||||
## Rollback
|
||||
|
||||
### Portainer:
|
||||
1. Go to Stack → Editor
|
||||
2. Change `IMAGE_TAG` to previous version
|
||||
3. Update stack
|
||||
|
||||
### Direct Docker:
|
||||
```bash
|
||||
cd /opt/rmtpocketwatcher
|
||||
git checkout <previous-commit>
|
||||
docker-compose up -d --build backend
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Stack won't start:**
|
||||
- Check environment variables are set
|
||||
- Verify database connection
|
||||
- Check logs: `docker-compose logs backend`
|
||||
|
||||
**Portainer API fails:**
|
||||
- Verify API key is valid
|
||||
- Check endpoint ID matches your environment
|
||||
- Ensure Portainer version supports Git deployment
|
||||
|
||||
**SSH deployment fails:**
|
||||
- Verify SSH key has correct permissions (600)
|
||||
- Check user has Docker permissions
|
||||
- Ensure Git is installed on remote host
|
||||
75
deploy/docker-deploy.sh
Normal file
75
deploy/docker-deploy.sh
Normal file
@@ -0,0 +1,75 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Direct Docker Deployment Script
|
||||
# This script deploys rmtPocketWatcher directly to a Docker host via SSH
|
||||
|
||||
set -e
|
||||
|
||||
# Configuration
|
||||
DOCKER_HOST="${DOCKER_HOST:-localhost}"
|
||||
DOCKER_USER="${DOCKER_USER:-root}"
|
||||
DEPLOY_PATH="${DEPLOY_PATH:-/opt/rmtpocketwatcher}"
|
||||
GIT_REPO="${GIT_REPO}"
|
||||
GIT_BRANCH="${GIT_BRANCH:-main}"
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
echo -e "${GREEN}Deploying rmtPocketWatcher to Docker host...${NC}"
|
||||
|
||||
# Check if we're deploying locally or remotely
|
||||
if [ "$DOCKER_HOST" = "localhost" ]; then
|
||||
echo -e "${YELLOW}Deploying locally...${NC}"
|
||||
|
||||
# Pull latest code
|
||||
if [ -d "$DEPLOY_PATH" ]; then
|
||||
cd "$DEPLOY_PATH"
|
||||
git pull origin "$GIT_BRANCH"
|
||||
else
|
||||
git clone -b "$GIT_BRANCH" "$GIT_REPO" "$DEPLOY_PATH"
|
||||
cd "$DEPLOY_PATH"
|
||||
fi
|
||||
|
||||
# Build and deploy
|
||||
docker-compose down
|
||||
docker-compose build backend
|
||||
docker-compose up -d
|
||||
|
||||
echo -e "${GREEN}Local deployment complete!${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}Deploying to remote host: ${DOCKER_HOST}${NC}"
|
||||
|
||||
# Deploy via SSH
|
||||
ssh ${DOCKER_USER}@${DOCKER_HOST} << EOF
|
||||
set -e
|
||||
|
||||
# Pull or clone repository
|
||||
if [ -d "${DEPLOY_PATH}" ]; then
|
||||
cd "${DEPLOY_PATH}"
|
||||
git pull origin ${GIT_BRANCH}
|
||||
else
|
||||
git clone -b ${GIT_BRANCH} ${GIT_REPO} ${DEPLOY_PATH}
|
||||
cd "${DEPLOY_PATH}"
|
||||
fi
|
||||
|
||||
# Build and deploy
|
||||
docker-compose down
|
||||
docker-compose build backend
|
||||
docker-compose up -d
|
||||
|
||||
echo "Deployment complete!"
|
||||
EOF
|
||||
|
||||
echo -e "${GREEN}Remote deployment complete!${NC}"
|
||||
fi
|
||||
|
||||
# Show status
|
||||
echo -e "${YELLOW}Checking service status...${NC}"
|
||||
if [ "$DOCKER_HOST" = "localhost" ]; then
|
||||
docker-compose ps
|
||||
else
|
||||
ssh ${DOCKER_USER}@${DOCKER_HOST} "cd ${DEPLOY_PATH} && docker-compose ps"
|
||||
fi
|
||||
76
deploy/portainer-deploy.sh
Normal file
76
deploy/portainer-deploy.sh
Normal file
@@ -0,0 +1,76 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Portainer Deployment Script
|
||||
# This script deploys or updates the rmtPocketWatcher stack via Portainer API
|
||||
|
||||
set -e
|
||||
|
||||
# Configuration
|
||||
PORTAINER_URL="${PORTAINER_URL:-http://localhost:9000}"
|
||||
PORTAINER_API_KEY="${PORTAINER_API_KEY}"
|
||||
STACK_NAME="${STACK_NAME:-rmtpocketwatcher}"
|
||||
ENDPOINT_ID="${ENDPOINT_ID:-1}"
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
# Check required variables
|
||||
if [ -z "$PORTAINER_API_KEY" ]; then
|
||||
echo -e "${RED}Error: PORTAINER_API_KEY is not set${NC}"
|
||||
echo "Usage: PORTAINER_API_KEY=your_key ./portainer-deploy.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}Deploying rmtPocketWatcher to Portainer...${NC}"
|
||||
|
||||
# Check if stack exists
|
||||
STACK_ID=$(curl -s -X GET \
|
||||
"${PORTAINER_URL}/api/stacks" \
|
||||
-H "X-API-Key: ${PORTAINER_API_KEY}" \
|
||||
| jq -r ".[] | select(.Name==\"${STACK_NAME}\") | .Id")
|
||||
|
||||
if [ -z "$STACK_ID" ] || [ "$STACK_ID" = "null" ]; then
|
||||
echo -e "${YELLOW}Stack not found. Creating new stack...${NC}"
|
||||
|
||||
# Create new stack from Git repository
|
||||
curl -X POST \
|
||||
"${PORTAINER_URL}/api/stacks/create/standalone/repository" \
|
||||
-H "X-API-Key: ${PORTAINER_API_KEY}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d @- << EOF
|
||||
{
|
||||
"name": "${STACK_NAME}",
|
||||
"endpointId": ${ENDPOINT_ID},
|
||||
"repositoryURL": "${GIT_REPO_URL}",
|
||||
"repositoryReferenceName": "refs/heads/main",
|
||||
"composeFile": "deploy/portainer-stack.yml",
|
||||
"repositoryAuthentication": false,
|
||||
"env": [
|
||||
{"name": "POSTGRES_PASSWORD", "value": "${POSTGRES_PASSWORD}"},
|
||||
{"name": "IMAGE_TAG", "value": "latest"}
|
||||
]
|
||||
}
|
||||
EOF
|
||||
|
||||
echo -e "${GREEN}Stack created successfully!${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}Stack found (ID: ${STACK_ID}). Updating...${NC}"
|
||||
|
||||
# Update existing stack via Git pull
|
||||
curl -X PUT \
|
||||
"${PORTAINER_URL}/api/stacks/${STACK_ID}/git/redeploy" \
|
||||
-H "X-API-Key: ${PORTAINER_API_KEY}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"pullImage": true,
|
||||
"prune": true
|
||||
}'
|
||||
|
||||
echo -e "${GREEN}Stack updated successfully!${NC}"
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}Deployment complete!${NC}"
|
||||
echo -e "Access your backend at: ${PORTAINER_URL%:*}:3000"
|
||||
52
deploy/portainer-stack.yml
Normal file
52
deploy/portainer-stack.yml
Normal file
@@ -0,0 +1,52 @@
|
||||
version: '3.8'
|
||||
|
||||
# Portainer Stack Configuration
|
||||
# This file is used when deploying via Portainer's Git repository feature
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-rmtpw}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-rmtpocketwatcher}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- rmtpw-network
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-rmtpw} -d ${POSTGRES_DB:-rmtpocketwatcher}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
backend:
|
||||
image: ${DOCKER_REGISTRY:-ghcr.io}/${DOCKER_IMAGE:-lambdabanking/rmtpocketwatcher-backend}:${IMAGE_TAG:-latest}
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-rmtpw}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-rmtpocketwatcher}?schema=public
|
||||
PORT: ${BACKEND_PORT:-3000}
|
||||
HOST: 0.0.0.0
|
||||
NODE_ENV: production
|
||||
SCRAPE_INTERVAL_MINUTES: ${SCRAPE_INTERVAL:-5}
|
||||
SCRAPER_HEADLESS: "true"
|
||||
SCRAPER_TIMEOUT: ${SCRAPER_TIMEOUT:-30000}
|
||||
SCRAPER_MAX_RETRIES: ${SCRAPER_MAX_RETRIES:-3}
|
||||
ports:
|
||||
- "${BACKEND_PORT:-3000}:3000"
|
||||
networks:
|
||||
- rmtpw-network
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=true"
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
rmtpw-network:
|
||||
driver: bridge
|
||||
@@ -16,6 +16,7 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: #0a0e27;
|
||||
}
|
||||
|
||||
#root {
|
||||
@@ -23,6 +24,8 @@
|
||||
height: 100%;
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
border: 2px solid #50e3c2;
|
||||
box-shadow: 0 0 20px rgba(80, 227, 194, 0.3);
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
|
||||
@@ -10,15 +10,20 @@
|
||||
"build": "npm run build:main && npm run build:renderer",
|
||||
"electron:dev": "concurrently \"npm run dev\" \"wait-on http://localhost:5173 && cross-env NODE_ENV=development electron dist/main/index.js\"",
|
||||
"electron:build": "npm run build && electron-builder",
|
||||
"publish": "npm run build && electron-builder --publish always",
|
||||
"rebuild": "electron-rebuild",
|
||||
"postinstall": "electron-rebuild",
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^12.5.0",
|
||||
"electron": "^30.0.0",
|
||||
"electron-updater": "^6.1.0",
|
||||
"recharts": "^3.5.1",
|
||||
"ws": "^8.16.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^20.10.0",
|
||||
"@types/react": "^18.3.27",
|
||||
"@types/ws": "^8.5.10",
|
||||
@@ -26,6 +31,7 @@
|
||||
"concurrently": "^8.2.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"electron-builder": "^24.9.0",
|
||||
"electron-rebuild": "^3.2.9",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"typescript": "^5.3.0",
|
||||
@@ -42,6 +48,10 @@
|
||||
"dist/**/*",
|
||||
"package.json"
|
||||
],
|
||||
"publish": {
|
||||
"provider": "generic",
|
||||
"url": "${env.RELEASE_URL}"
|
||||
},
|
||||
"win": {
|
||||
"target": [
|
||||
"nsis"
|
||||
@@ -54,8 +64,10 @@
|
||||
},
|
||||
"linux": {
|
||||
"target": [
|
||||
"AppImage"
|
||||
]
|
||||
"AppImage",
|
||||
"deb"
|
||||
],
|
||||
"category": "Finance"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
88
electron-app/src/main/database.ts
Normal file
88
electron-app/src/main/database.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { app } from 'electron';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
export interface PriceAlert {
|
||||
id: string;
|
||||
auecAmount: number;
|
||||
maxPrice: number;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
let db: Database.Database | null = null;
|
||||
|
||||
export function initDatabase() {
|
||||
const userDataPath = app.getPath('userData');
|
||||
const dbPath = path.join(userDataPath, 'alerts.db');
|
||||
|
||||
// Ensure directory exists
|
||||
if (!fs.existsSync(userDataPath)) {
|
||||
fs.mkdirSync(userDataPath, { recursive: true });
|
||||
}
|
||||
|
||||
db = new Database(dbPath);
|
||||
|
||||
// Create alerts table
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS alerts (
|
||||
id TEXT PRIMARY KEY,
|
||||
auecAmount REAL NOT NULL,
|
||||
maxPrice REAL NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1
|
||||
)
|
||||
`);
|
||||
|
||||
console.log('Database initialized at:', dbPath);
|
||||
}
|
||||
|
||||
export function getAllAlerts(): PriceAlert[] {
|
||||
if (!db) throw new Error('Database not initialized');
|
||||
|
||||
const stmt = db.prepare('SELECT * FROM alerts');
|
||||
const rows = stmt.all() as any[];
|
||||
|
||||
return rows.map(row => ({
|
||||
id: row.id,
|
||||
auecAmount: row.auecAmount,
|
||||
maxPrice: row.maxPrice,
|
||||
enabled: row.enabled === 1,
|
||||
}));
|
||||
}
|
||||
|
||||
export function addAlert(alert: PriceAlert): void {
|
||||
if (!db) throw new Error('Database not initialized');
|
||||
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO alerts (id, auecAmount, maxPrice, enabled)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
stmt.run(alert.id, alert.auecAmount, alert.maxPrice, alert.enabled ? 1 : 0);
|
||||
}
|
||||
|
||||
export function updateAlert(alert: PriceAlert): void {
|
||||
if (!db) throw new Error('Database not initialized');
|
||||
|
||||
const stmt = db.prepare(`
|
||||
UPDATE alerts
|
||||
SET auecAmount = ?, maxPrice = ?, enabled = ?
|
||||
WHERE id = ?
|
||||
`);
|
||||
|
||||
stmt.run(alert.auecAmount, alert.maxPrice, alert.enabled ? 1 : 0, alert.id);
|
||||
}
|
||||
|
||||
export function deleteAlert(id: string): void {
|
||||
if (!db) throw new Error('Database not initialized');
|
||||
|
||||
const stmt = db.prepare('DELETE FROM alerts WHERE id = ?');
|
||||
stmt.run(id);
|
||||
}
|
||||
|
||||
export function closeDatabase(): void {
|
||||
if (db) {
|
||||
db.close();
|
||||
db = null;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,19 @@
|
||||
import { app, BrowserWindow } from 'electron';
|
||||
import { app, BrowserWindow, Tray, Menu, nativeImage } from 'electron';
|
||||
import * as path from 'path';
|
||||
import { setupIpcHandlers, cleanupIpcHandlers } from './ipc-handlers';
|
||||
import { initDatabase, closeDatabase } from './database';
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let tray: Tray | null = null;
|
||||
|
||||
function createWindow(): void {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1400,
|
||||
height: 900,
|
||||
minWidth: 1000,
|
||||
minHeight: 700,
|
||||
frame: false,
|
||||
backgroundColor: '#0a0e27',
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
nodeIntegration: false,
|
||||
@@ -34,12 +40,53 @@ function createWindow(): void {
|
||||
});
|
||||
}
|
||||
|
||||
app.whenReady().then(createWindow);
|
||||
function createTray(): void {
|
||||
// Create a simple icon for the tray (16x16 cyan square)
|
||||
const icon = nativeImage.createFromDataURL(
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAAdgAAAHYBTnsmCAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAABNSURBVDiNY/z//z8DJYCJgUIwasCoAaMGjBowaMIAhv///zNQCpiYKASjBowaMGrAqAGDJgxGDRg1YNSAUQNGDRg0YTBqwKgBowYMmjAAALmyAwVYbMsAAAAASUVORK5CYII='
|
||||
);
|
||||
|
||||
tray = new Tray(icon);
|
||||
|
||||
const contextMenu = Menu.buildFromTemplate([
|
||||
{
|
||||
label: 'Show rmtPocketWatcher',
|
||||
click: () => {
|
||||
if (mainWindow) {
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Quit',
|
||||
click: () => {
|
||||
app.quit();
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
tray.setToolTip('rmtPocketWatcher');
|
||||
tray.setContextMenu(contextMenu);
|
||||
|
||||
// Double-click to show window
|
||||
tray.on('double-click', () => {
|
||||
if (mainWindow) {
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit();
|
||||
}
|
||||
app.whenReady().then(() => {
|
||||
initDatabase();
|
||||
createTray();
|
||||
createWindow();
|
||||
});
|
||||
|
||||
app.on('window-all-closed', (e: Event) => {
|
||||
// Prevent app from quitting when window is closed (allow tray to keep it running)
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
app.on('activate', () => {
|
||||
@@ -50,4 +97,5 @@ app.on('activate', () => {
|
||||
|
||||
app.on('before-quit', () => {
|
||||
cleanupIpcHandlers();
|
||||
closeDatabase();
|
||||
});
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { ipcMain, BrowserWindow } from 'electron';
|
||||
import { WebSocketClient } from './websocket-client';
|
||||
import { PriceIndex } from '../shared/types';
|
||||
import * as db from './database';
|
||||
|
||||
let wsClient: WebSocketClient | null = null;
|
||||
|
||||
export function setupIpcHandlers(mainWindow: BrowserWindow): void {
|
||||
const wsUrl = process.env.WS_URL || 'ws://localhost:3000/ws';
|
||||
|
||||
// Initialize WebSocket client
|
||||
wsClient = new WebSocketClient(wsUrl);
|
||||
// Initialize WebSocket client only once
|
||||
if (!wsClient) {
|
||||
wsClient = new WebSocketClient(wsUrl);
|
||||
}
|
||||
|
||||
// Forward WebSocket events to renderer
|
||||
wsClient.on('connected', () => {
|
||||
@@ -35,6 +38,22 @@ export function setupIpcHandlers(mainWindow: BrowserWindow): void {
|
||||
mainWindow.webContents.send('ws:maxReconnectAttemptsReached');
|
||||
});
|
||||
|
||||
// Remove existing handlers
|
||||
try { ipcMain.removeHandler('ws:connect'); } catch (e) { /* ignore */ }
|
||||
try { ipcMain.removeHandler('ws:disconnect'); } catch (e) { /* ignore */ }
|
||||
try { ipcMain.removeHandler('ws:getStatus'); } catch (e) { /* ignore */ }
|
||||
try { ipcMain.removeHandler('ws:send'); } catch (e) { /* ignore */ }
|
||||
try { ipcMain.removeHandler('api:getLatestPrices'); } catch (e) { /* ignore */ }
|
||||
try { ipcMain.removeHandler('window:minimize'); } catch (e) { /* ignore */ }
|
||||
try { ipcMain.removeHandler('window:maximize'); } catch (e) { /* ignore */ }
|
||||
try { ipcMain.removeHandler('window:close'); } catch (e) { /* ignore */ }
|
||||
try { ipcMain.removeHandler('window:isMaximized'); } catch (e) { /* ignore */ }
|
||||
try { ipcMain.removeHandler('window:hideToTray'); } catch (e) { /* ignore */ }
|
||||
try { ipcMain.removeHandler('alerts:getAll'); } catch (e) { /* ignore */ }
|
||||
try { ipcMain.removeHandler('alerts:add'); } catch (e) { /* ignore */ }
|
||||
try { ipcMain.removeHandler('alerts:update'); } catch (e) { /* ignore */ }
|
||||
try { ipcMain.removeHandler('alerts:delete'); } catch (e) { /* ignore */ }
|
||||
|
||||
// IPC handlers for renderer requests
|
||||
ipcMain.handle('ws:connect', async () => {
|
||||
wsClient?.connect();
|
||||
@@ -58,16 +77,111 @@ export function setupIpcHandlers(mainWindow: BrowserWindow): void {
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// Fetch initial data from API
|
||||
ipcMain.handle('api:getLatestPrices', async () => {
|
||||
try {
|
||||
const apiUrl = process.env.API_URL || 'http://localhost:3000';
|
||||
const response = await fetch(`${apiUrl}/api/prices/latest`);
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch latest prices:', error);
|
||||
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
|
||||
}
|
||||
});
|
||||
|
||||
// Window control handlers
|
||||
ipcMain.handle('window:minimize', () => {
|
||||
mainWindow.minimize();
|
||||
});
|
||||
|
||||
ipcMain.handle('window:maximize', () => {
|
||||
if (mainWindow.isMaximized()) {
|
||||
mainWindow.unmaximize();
|
||||
} else {
|
||||
mainWindow.maximize();
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('window:close', () => {
|
||||
mainWindow.close();
|
||||
});
|
||||
|
||||
ipcMain.handle('window:isMaximized', () => {
|
||||
return mainWindow.isMaximized();
|
||||
});
|
||||
|
||||
ipcMain.handle('window:hideToTray', () => {
|
||||
mainWindow.hide();
|
||||
});
|
||||
|
||||
// Alert management handlers
|
||||
ipcMain.handle('alerts:getAll', async () => {
|
||||
try {
|
||||
return db.getAllAlerts();
|
||||
} catch (error) {
|
||||
console.error('Failed to get alerts:', error);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('alerts:add', async (_event, alert: db.PriceAlert) => {
|
||||
try {
|
||||
db.addAlert(alert);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Failed to add alert:', error);
|
||||
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('alerts:update', async (_event, alert: db.PriceAlert) => {
|
||||
try {
|
||||
db.updateAlert(alert);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Failed to update alert:', error);
|
||||
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('alerts:delete', async (_event, id: string) => {
|
||||
try {
|
||||
db.deleteAlert(id);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Failed to delete alert:', error);
|
||||
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-connect on startup
|
||||
wsClient.connect();
|
||||
}
|
||||
|
||||
export function cleanupIpcHandlers(): void {
|
||||
wsClient?.disconnect();
|
||||
wsClient = null;
|
||||
if (wsClient) {
|
||||
wsClient.disconnect();
|
||||
wsClient = null;
|
||||
}
|
||||
|
||||
ipcMain.removeHandler('ws:connect');
|
||||
ipcMain.removeHandler('ws:disconnect');
|
||||
ipcMain.removeHandler('ws:getStatus');
|
||||
ipcMain.removeHandler('ws:send');
|
||||
// Safely remove handlers (won't throw if they don't exist)
|
||||
try {
|
||||
ipcMain.removeHandler('ws:connect');
|
||||
ipcMain.removeHandler('ws:disconnect');
|
||||
ipcMain.removeHandler('ws:getStatus');
|
||||
ipcMain.removeHandler('ws:send');
|
||||
ipcMain.removeHandler('api:getLatestPrices');
|
||||
ipcMain.removeHandler('window:minimize');
|
||||
ipcMain.removeHandler('window:maximize');
|
||||
ipcMain.removeHandler('window:close');
|
||||
ipcMain.removeHandler('window:isMaximized');
|
||||
ipcMain.removeHandler('window:hideToTray');
|
||||
ipcMain.removeHandler('alerts:getAll');
|
||||
ipcMain.removeHandler('alerts:add');
|
||||
ipcMain.removeHandler('alerts:update');
|
||||
ipcMain.removeHandler('alerts:delete');
|
||||
} catch (error) {
|
||||
// Handlers may not exist, ignore
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { useWebSocket } from './hooks/useWebSocket';
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
|
||||
import { TitleBar } from './components/TitleBar';
|
||||
|
||||
const TIME_RANGES = [
|
||||
{ label: '6 Hours', value: '6h' },
|
||||
{ label: '24 Hours', value: '24h' },
|
||||
{ label: '3 Days', value: '3d' },
|
||||
{ label: '7 Days', value: '7d' },
|
||||
{ label: '1 Month', value: '1mo' },
|
||||
{ label: '6H', value: '6h' },
|
||||
{ label: '24H', value: '24h' },
|
||||
{ label: '3D', value: '3d' },
|
||||
{ label: '7D', value: '7d' },
|
||||
{ label: '1M', value: '1mo' },
|
||||
{ label: 'YTD', value: 'ytd' },
|
||||
];
|
||||
|
||||
@@ -16,19 +17,146 @@ const COLORS = [
|
||||
'#50e3c2', '#ff6b9d', '#4a90e2', '#7ed321', '#d0021b', '#f8e71c'
|
||||
];
|
||||
|
||||
interface ZoomState {
|
||||
xStart: number;
|
||||
xEnd: number;
|
||||
yMin: number;
|
||||
yMax: number;
|
||||
}
|
||||
|
||||
interface PriceAlert {
|
||||
id: string;
|
||||
auecAmount: number;
|
||||
maxPrice: number;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface AlertNotification {
|
||||
id: string;
|
||||
message: string;
|
||||
sellerName: string;
|
||||
platform: string;
|
||||
price: number;
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const { status, latestPrice, historyData, requestHistory } = useWebSocket();
|
||||
const { status, latestPrice, historyData, requestHistory, fetchInitialData } = useWebSocket();
|
||||
const [selectedRange, setSelectedRange] = useState('7d');
|
||||
const [zoomLevel, setZoomLevel] = useState(1);
|
||||
const [zoomState, setZoomState] = useState<ZoomState | null>(null);
|
||||
const chartContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Price Alert State
|
||||
const [alerts, setAlerts] = useState<PriceAlert[]>([]);
|
||||
const [newAlertAuec, setNewAlertAuec] = useState('');
|
||||
const [newAlertPrice, setNewAlertPrice] = useState('');
|
||||
const [alertNotification, setAlertNotification] = useState<AlertNotification | null>(null);
|
||||
const alertAudioRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
// Load alerts from database on mount
|
||||
useEffect(() => {
|
||||
if (status.connected) {
|
||||
requestHistory(selectedRange);
|
||||
}
|
||||
}, [status.connected, selectedRange, requestHistory]);
|
||||
const loadAlerts = async () => {
|
||||
try {
|
||||
const savedAlerts = await window.electron.ipcRenderer.invoke('alerts:getAll');
|
||||
setAlerts(savedAlerts);
|
||||
} catch (error) {
|
||||
console.error('Failed to load alerts:', error);
|
||||
}
|
||||
};
|
||||
loadAlerts();
|
||||
}, []);
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
// Fetch initial data on mount
|
||||
useEffect(() => {
|
||||
fetchInitialData();
|
||||
}, [fetchInitialData]);
|
||||
|
||||
// Request historical data when range changes
|
||||
useEffect(() => {
|
||||
requestHistory(selectedRange);
|
||||
}, [selectedRange, requestHistory]);
|
||||
|
||||
// Check for price alerts
|
||||
useEffect(() => {
|
||||
if (!latestPrice || alerts.length === 0) return;
|
||||
|
||||
alerts.forEach(alert => {
|
||||
if (!alert.enabled) return;
|
||||
|
||||
// Check if any seller meets the alert criteria
|
||||
const matchingSeller = latestPrice.allPrices.find(price => {
|
||||
const totalPrice = (alert.auecAmount / 1000000) * price.pricePerMillion;
|
||||
return totalPrice <= alert.maxPrice;
|
||||
});
|
||||
|
||||
if (matchingSeller) {
|
||||
const totalPrice = (alert.auecAmount / 1000000) * matchingSeller.pricePerMillion;
|
||||
|
||||
// Show notification
|
||||
const notification = {
|
||||
id: Date.now().toString(),
|
||||
message: `${alert.auecAmount.toLocaleString()} AUEC available for $${totalPrice.toFixed(2)}`,
|
||||
sellerName: matchingSeller.sellerName,
|
||||
platform: matchingSeller.platform,
|
||||
price: totalPrice,
|
||||
};
|
||||
|
||||
setAlertNotification(notification);
|
||||
|
||||
// Play alert sound
|
||||
if (alertAudioRef.current) {
|
||||
alertAudioRef.current.play().catch(e => console.error('Audio play failed:', e));
|
||||
}
|
||||
|
||||
// OS notification
|
||||
if (window.Notification && Notification.permission === 'granted') {
|
||||
new Notification('Price Alert - rmtPocketWatcher', {
|
||||
body: `${notification.message}\nSeller: ${matchingSeller.sellerName} (${matchingSeller.platform})`,
|
||||
icon: 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y="75" font-size="75">💰</text></svg>'
|
||||
});
|
||||
}
|
||||
|
||||
// Disable alert after triggering
|
||||
const disabledAlert = { ...alert, enabled: false };
|
||||
window.electron.ipcRenderer.invoke('alerts:update', disabledAlert).catch(err =>
|
||||
console.error('Failed to update alert in DB:', err)
|
||||
);
|
||||
setAlerts(prev => prev.map(a =>
|
||||
a.id === alert.id ? disabledAlert : a
|
||||
));
|
||||
}
|
||||
});
|
||||
}, [latestPrice, alerts]);
|
||||
|
||||
// Request notification permission on mount
|
||||
useEffect(() => {
|
||||
if (window.Notification && Notification.permission === 'default') {
|
||||
Notification.requestPermission();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Helper function to format dates elegantly
|
||||
const formatDate = (timestamp: number) => {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffHours = diffMs / (1000 * 60 * 60);
|
||||
const diffDays = diffMs / (1000 * 60 * 60 * 24);
|
||||
|
||||
// If within last 24 hours, show time
|
||||
if (diffHours < 24) {
|
||||
return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
// If within last 7 days, show day and time
|
||||
else if (diffDays < 7) {
|
||||
return date.toLocaleDateString('en-US', { weekday: 'short', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
// Otherwise show date
|
||||
else {
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
}
|
||||
};
|
||||
|
||||
const fullChartData = useMemo(() => {
|
||||
if (!historyData?.prices) return [];
|
||||
|
||||
// Group by timestamp and seller
|
||||
@@ -39,7 +167,11 @@ export function App() {
|
||||
const key = time.toString();
|
||||
|
||||
if (!grouped.has(key)) {
|
||||
grouped.set(key, { timestamp: time, time: new Date(price.timestamp).toLocaleString() });
|
||||
grouped.set(key, {
|
||||
timestamp: time,
|
||||
time: formatDate(time),
|
||||
fullTime: new Date(price.timestamp).toLocaleString()
|
||||
});
|
||||
}
|
||||
|
||||
const entry = grouped.get(key);
|
||||
@@ -50,15 +182,41 @@ export function App() {
|
||||
return Array.from(grouped.values()).sort((a, b) => a.timestamp - b.timestamp);
|
||||
}, [historyData]);
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
if (!fullChartData.length) return [];
|
||||
if (!zoomState) return fullChartData;
|
||||
|
||||
// Find data points within the zoom range, plus one point on each side for continuity
|
||||
const filtered = fullChartData.filter(d =>
|
||||
d.timestamp >= zoomState.xStart && d.timestamp <= zoomState.xEnd
|
||||
);
|
||||
|
||||
// If we have filtered data, return it
|
||||
if (filtered.length > 0) return filtered;
|
||||
|
||||
// If no exact matches, find the closest points to show something
|
||||
// This prevents empty chart when zoomed between data points
|
||||
const closestBefore = fullChartData.filter(d => d.timestamp < zoomState.xStart).slice(-2);
|
||||
const closestAfter = fullChartData.filter(d => d.timestamp > zoomState.xEnd).slice(0, 2);
|
||||
|
||||
return [...closestBefore, ...closestAfter];
|
||||
}, [fullChartData, zoomState]);
|
||||
|
||||
const sellers = useMemo(() => {
|
||||
if (!historyData?.prices) return [];
|
||||
const uniqueSellers = new Set(historyData.prices.map(p => p.sellerName));
|
||||
return Array.from(uniqueSellers);
|
||||
}, [historyData]);
|
||||
|
||||
const { minY, maxY } = useMemo(() => {
|
||||
const yAxisDomain = useMemo(() => {
|
||||
// Use zoom state if available
|
||||
if (zoomState) {
|
||||
return [zoomState.yMin, zoomState.yMax];
|
||||
}
|
||||
|
||||
// Default calculation
|
||||
if (!historyData?.prices || historyData.prices.length === 0) {
|
||||
return { minY: 0, maxY: 1 };
|
||||
return [0, 1];
|
||||
}
|
||||
|
||||
// Get all prices and sort them
|
||||
@@ -74,66 +232,225 @@ export function App() {
|
||||
// Base max Y on 90th percentile with padding
|
||||
const baseMaxY = Math.max(percentile90 * 1.1, minPrice * 2);
|
||||
|
||||
// Apply zoom level (higher zoom = smaller range)
|
||||
const zoomedMaxY = baseMaxY / zoomLevel;
|
||||
|
||||
console.log('Y-axis domain:', { minY: 0, maxY: zoomedMaxY, zoom: zoomLevel, base: baseMaxY });
|
||||
|
||||
return { minY: 0, maxY: zoomedMaxY };
|
||||
}, [historyData, zoomLevel]);
|
||||
return [0, baseMaxY];
|
||||
}, [historyData, zoomState]);
|
||||
|
||||
useEffect(() => {
|
||||
const chartContainer = chartContainerRef.current;
|
||||
if (!chartContainer) {
|
||||
console.log('Chart container ref not found');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Setting up wheel event listener on chart container');
|
||||
if (!chartContainer || !fullChartData.length) return;
|
||||
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
console.log('Wheel event:', e.deltaY);
|
||||
const isZoomIn = e.deltaY < 0;
|
||||
const zoomFactor = isZoomIn ? 0.8 : 1.25; // Zoom in = smaller range, zoom out = larger range
|
||||
|
||||
// Zoom in when scrolling up (negative deltaY), zoom out when scrolling down
|
||||
const zoomDelta = e.deltaY > 0 ? 0.9 : 1.1;
|
||||
// Get mouse position relative to chart
|
||||
const rect = chartContainer.getBoundingClientRect();
|
||||
const mouseX = e.clientX - rect.left;
|
||||
|
||||
setZoomLevel(prev => {
|
||||
const newZoom = prev * zoomDelta;
|
||||
const clampedZoom = Math.max(0.1, Math.min(10, newZoom));
|
||||
console.log('Zoom level:', prev, '->', clampedZoom);
|
||||
return clampedZoom;
|
||||
// Calculate mouse X position as percentage of chart area
|
||||
const xPercent = mouseX / rect.width;
|
||||
|
||||
// Get current ranges
|
||||
const currentXStart = zoomState?.xStart ?? fullChartData[0].timestamp;
|
||||
const currentXEnd = zoomState?.xEnd ?? fullChartData[fullChartData.length - 1].timestamp;
|
||||
const currentYMax = zoomState?.yMax ?? yAxisDomain[1];
|
||||
|
||||
// Calculate current X range
|
||||
const xRange = currentXEnd - currentXStart;
|
||||
|
||||
// Calculate new X range centered on mouse position
|
||||
let newXRange = xRange * zoomFactor;
|
||||
|
||||
// Prevent zooming in too much - ensure minimum time range
|
||||
// Calculate minimum based on data density
|
||||
const fullDataRange = fullChartData[fullChartData.length - 1].timestamp - fullChartData[0].timestamp;
|
||||
const minTimeRange = fullDataRange / 1000; // Allow up to 1000x zoom
|
||||
|
||||
if (isZoomIn && newXRange < minTimeRange) {
|
||||
newXRange = minTimeRange;
|
||||
}
|
||||
|
||||
// Calculate mouse position in X data coordinates
|
||||
const mouseXData = currentXStart + xRange * xPercent;
|
||||
|
||||
// Calculate new X bounds centered on mouse position
|
||||
let newXStart = mouseXData - newXRange * xPercent;
|
||||
let newXEnd = mouseXData + newXRange * (1 - xPercent);
|
||||
|
||||
// Constrain X to data bounds
|
||||
const dataXStart = fullChartData[0].timestamp;
|
||||
const dataXEnd = fullChartData[fullChartData.length - 1].timestamp;
|
||||
|
||||
if (newXStart < dataXStart) {
|
||||
newXEnd += dataXStart - newXStart;
|
||||
newXStart = dataXStart;
|
||||
}
|
||||
if (newXEnd > dataXEnd) {
|
||||
newXStart -= newXEnd - dataXEnd;
|
||||
newXEnd = dataXEnd;
|
||||
}
|
||||
newXStart = Math.max(dataXStart, newXStart);
|
||||
newXEnd = Math.min(dataXEnd, newXEnd);
|
||||
|
||||
// Y-axis: always zoom from 0, just adjust the max
|
||||
const newYMax = currentYMax * zoomFactor;
|
||||
|
||||
setZoomState({
|
||||
xStart: newXStart,
|
||||
xEnd: newXEnd,
|
||||
yMin: 0,
|
||||
yMax: newYMax,
|
||||
});
|
||||
};
|
||||
|
||||
chartContainer.addEventListener('wheel', handleWheel, { passive: false });
|
||||
|
||||
return () => {
|
||||
console.log('Removing wheel event listener');
|
||||
chartContainer.removeEventListener('wheel', handleWheel);
|
||||
};
|
||||
}, []);
|
||||
}, [fullChartData, zoomState, yAxisDomain]);
|
||||
|
||||
const handleRangeChange = (range: string) => {
|
||||
setSelectedRange(range);
|
||||
setZoomState(null);
|
||||
};
|
||||
|
||||
const resetZoom = () => {
|
||||
setZoomLevel(1);
|
||||
setZoomState(null);
|
||||
};
|
||||
|
||||
const addAlert = async () => {
|
||||
const auec = parseFloat(newAlertAuec);
|
||||
const price = parseFloat(newAlertPrice);
|
||||
|
||||
if (isNaN(auec) || isNaN(price) || auec <= 0 || price <= 0) {
|
||||
alert('Please enter valid amounts');
|
||||
return;
|
||||
}
|
||||
|
||||
const newAlert: PriceAlert = {
|
||||
id: Date.now().toString(),
|
||||
auecAmount: auec,
|
||||
maxPrice: price,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
try {
|
||||
await window.electron.ipcRenderer.invoke('alerts:add', newAlert);
|
||||
setAlerts(prev => [...prev, newAlert]);
|
||||
setNewAlertAuec('');
|
||||
setNewAlertPrice('');
|
||||
} catch (error) {
|
||||
console.error('Failed to add alert:', error);
|
||||
alert('Failed to add alert');
|
||||
}
|
||||
};
|
||||
|
||||
const toggleAlert = async (id: string) => {
|
||||
const alert = alerts.find(a => a.id === id);
|
||||
if (!alert) return;
|
||||
|
||||
const updatedAlert = { ...alert, enabled: !alert.enabled };
|
||||
|
||||
try {
|
||||
await window.electron.ipcRenderer.invoke('alerts:update', updatedAlert);
|
||||
setAlerts(prev => prev.map(a =>
|
||||
a.id === id ? updatedAlert : a
|
||||
));
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle alert:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteAlert = async (id: string) => {
|
||||
try {
|
||||
await window.electron.ipcRenderer.invoke('alerts:delete', id);
|
||||
setAlerts(prev => prev.filter(a => a.id !== id));
|
||||
} catch (error) {
|
||||
console.error('Failed to delete alert:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const dismissNotification = () => {
|
||||
setAlertNotification(null);
|
||||
};
|
||||
|
||||
const handleZoomIn = () => {
|
||||
if (!fullChartData.length) return;
|
||||
|
||||
const currentXStart = zoomState?.xStart ?? fullChartData[0].timestamp;
|
||||
const currentXEnd = zoomState?.xEnd ?? fullChartData[fullChartData.length - 1].timestamp;
|
||||
const currentYMax = zoomState?.yMax ?? yAxisDomain[1];
|
||||
|
||||
const xRange = currentXEnd - currentXStart;
|
||||
const newXRange = xRange * 0.8;
|
||||
|
||||
const xCenter = (currentXStart + currentXEnd) / 2;
|
||||
|
||||
// Y-axis: zoom from 0
|
||||
const newYMax = currentYMax * 0.8;
|
||||
|
||||
setZoomState({
|
||||
xStart: xCenter - newXRange / 2,
|
||||
xEnd: xCenter + newXRange / 2,
|
||||
yMin: 0,
|
||||
yMax: newYMax,
|
||||
});
|
||||
};
|
||||
|
||||
const handleZoomOut = () => {
|
||||
if (!fullChartData.length) return;
|
||||
|
||||
const currentXStart = zoomState?.xStart ?? fullChartData[0].timestamp;
|
||||
const currentXEnd = zoomState?.xEnd ?? fullChartData[fullChartData.length - 1].timestamp;
|
||||
const currentYMax = zoomState?.yMax ?? yAxisDomain[1];
|
||||
|
||||
const xRange = currentXEnd - currentXStart;
|
||||
const newXRange = xRange * 1.25;
|
||||
|
||||
const xCenter = (currentXStart + currentXEnd) / 2;
|
||||
|
||||
const dataXStart = fullChartData[0].timestamp;
|
||||
const dataXEnd = fullChartData[fullChartData.length - 1].timestamp;
|
||||
|
||||
const newXStart = Math.max(dataXStart, xCenter - newXRange / 2);
|
||||
const newXEnd = Math.min(dataXEnd, xCenter + newXRange / 2);
|
||||
|
||||
// Y-axis: zoom from 0
|
||||
const newYMax = currentYMax * 1.25;
|
||||
|
||||
setZoomState({
|
||||
xStart: newXStart,
|
||||
xEnd: newXEnd,
|
||||
yMin: 0,
|
||||
yMax: newYMax,
|
||||
});
|
||||
};
|
||||
|
||||
const getZoomLevel = () => {
|
||||
if (!zoomState || !fullChartData.length) return 1;
|
||||
|
||||
const fullXRange = fullChartData[fullChartData.length - 1].timestamp - fullChartData[0].timestamp;
|
||||
const currentXRange = zoomState.xEnd - zoomState.xStart;
|
||||
|
||||
return fullXRange / currentXRange;
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
padding: '20px',
|
||||
fontFamily: 'system-ui',
|
||||
backgroundColor: '#0a0e27',
|
||||
color: '#fff',
|
||||
minHeight: '100%'
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}>
|
||||
<h1 style={{ color: '#50e3c2', marginBottom: '10px' }}>rmtPocketWatcher</h1>
|
||||
<p style={{ color: '#888', fontSize: '14px', marginBottom: '20px' }}>Lambda Banking Conglomerate</p>
|
||||
<TitleBar />
|
||||
|
||||
<div style={{ padding: '20px', flex: 1 }}>
|
||||
|
||||
<div style={{ display: 'flex', gap: '20px', marginBottom: '20px' }}>
|
||||
<div style={{ flex: 1, backgroundColor: '#1a1f3a', padding: '15px', borderRadius: '8px' }}>
|
||||
@@ -163,21 +480,141 @@ export function App() {
|
||||
</div>
|
||||
|
||||
<div style={{ backgroundColor: '#1a1f3a', padding: '20px', borderRadius: '8px', marginBottom: '20px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
|
||||
<h2 style={{ margin: '0 0 15px 0' }}>Price Alerts</h2>
|
||||
|
||||
<div style={{ display: 'flex', gap: '10px', marginBottom: '15px', flexWrap: 'wrap' }}>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="AUEC Amount"
|
||||
value={newAlertAuec}
|
||||
onChange={(e) => setNewAlertAuec(e.target.value)}
|
||||
style={{
|
||||
padding: '10px',
|
||||
backgroundColor: '#2a2f4a',
|
||||
color: '#fff',
|
||||
border: '1px solid #50e3c2',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
flex: '1',
|
||||
minWidth: '150px',
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Max USD Price"
|
||||
value={newAlertPrice}
|
||||
onChange={(e) => setNewAlertPrice(e.target.value)}
|
||||
style={{
|
||||
padding: '10px',
|
||||
backgroundColor: '#2a2f4a',
|
||||
color: '#fff',
|
||||
border: '1px solid #50e3c2',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
flex: '1',
|
||||
minWidth: '150px',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={addAlert}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#50e3c2',
|
||||
color: '#0a0e27',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontWeight: 'bold',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Add Alert
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{alerts.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||
{alerts.map(alert => (
|
||||
<div
|
||||
key={alert.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '12px',
|
||||
backgroundColor: '#2a2f4a',
|
||||
borderRadius: '4px',
|
||||
border: `2px solid ${alert.enabled ? '#50e3c2' : '#888'}`,
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: '16px', fontWeight: 'bold' }}>
|
||||
{alert.auecAmount.toLocaleString()} AUEC for ${alert.maxPrice.toFixed(2)}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#888' }}>
|
||||
${(alert.maxPrice / (alert.auecAmount / 1000000)).toFixed(4)} per 1M AUEC
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
onClick={() => toggleAlert(alert.id)}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
backgroundColor: alert.enabled ? '#50e3c2' : '#888',
|
||||
color: '#0a0e27',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
{alert.enabled ? 'Enabled' : 'Disabled'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteAlert(alert.id)}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
backgroundColor: '#ff6b9d',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', padding: '20px', color: '#888' }}>
|
||||
No alerts set. Add an alert to get notified when prices meet your criteria.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ backgroundColor: '#1a1f3a', padding: '20px', borderRadius: '8px', marginBottom: '20px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '15px' }}>
|
||||
<h2 style={{ margin: 0 }}>Price History</h2>
|
||||
<div style={{ display: 'flex', gap: '10px' }}>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
{TIME_RANGES.map(range => (
|
||||
<button
|
||||
key={range.value}
|
||||
onClick={() => handleRangeChange(range.value)}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
padding: '6px 14px',
|
||||
backgroundColor: selectedRange === range.value ? '#50e3c2' : '#2a2f4a',
|
||||
color: selectedRange === range.value ? '#0a0e27' : '#fff',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontWeight: selectedRange === range.value ? 'bold' : 'normal',
|
||||
fontSize: '13px',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
>
|
||||
{range.label}
|
||||
@@ -186,28 +623,71 @@ export function App() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{historyData && historyData.prices && historyData.prices.length > 0 ? (
|
||||
chartData.length > 0 ? (
|
||||
<div>
|
||||
<div style={{ marginBottom: '10px', color: '#888', fontSize: '12px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span>Scroll on chart to zoom. Extreme outliers may be clipped for better visibility.</span>
|
||||
<button
|
||||
onClick={resetZoom}
|
||||
style={{
|
||||
padding: '4px 12px',
|
||||
backgroundColor: '#2a2f4a',
|
||||
color: '#fff',
|
||||
border: '1px solid #50e3c2',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '11px'
|
||||
}}
|
||||
>
|
||||
Reset Zoom (×{zoomLevel.toFixed(1)})
|
||||
</button>
|
||||
<div style={{
|
||||
marginBottom: '12px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: '15px',
|
||||
flexWrap: 'wrap'
|
||||
}}>
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
<button
|
||||
onClick={handleZoomOut}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
backgroundColor: '#2a2f4a',
|
||||
color: '#fff',
|
||||
border: '1px solid #50e3c2',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
title="Zoom Out"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<button
|
||||
onClick={handleZoomIn}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
backgroundColor: '#2a2f4a',
|
||||
color: '#fff',
|
||||
border: '1px solid #50e3c2',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
title="Zoom In"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
onClick={resetZoom}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
backgroundColor: '#2a2f4a',
|
||||
color: '#fff',
|
||||
border: '1px solid #50e3c2',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
>
|
||||
Reset (×{getZoomLevel().toFixed(1)})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ color: '#888', fontSize: '11px', fontStyle: 'italic' }}>
|
||||
Scroll on chart to zoom into area • Outliers may be clipped
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={chartContainerRef}
|
||||
style={{
|
||||
@@ -215,48 +695,64 @@ export function App() {
|
||||
height: '500px',
|
||||
minHeight: '400px',
|
||||
cursor: 'ns-resize',
|
||||
border: '1px solid #2a2f4a'
|
||||
border: '1px solid #2a2f4a',
|
||||
borderRadius: '4px',
|
||||
backgroundColor: '#0f1329',
|
||||
}}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={chartData} key={`chart-${zoomLevel}`}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#2a2f4a" />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
stroke="#888"
|
||||
tick={{ fill: '#888', fontSize: 10 }}
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={80}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="#888"
|
||||
tick={{ fill: '#888', fontSize: 12 }}
|
||||
label={{ value: 'USD per 1M AUEC', angle: -90, position: 'insideLeft', fill: '#888' }}
|
||||
domain={[0, yAxisDomain[1]]}
|
||||
allowDataOverflow={true}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#1a1f3a', border: '1px solid #2a2f4a', borderRadius: '4px' }}
|
||||
labelStyle={{ color: '#fff' }}
|
||||
/>
|
||||
<Legend
|
||||
wrapperStyle={{ color: '#fff', maxHeight: '100px', overflowY: 'auto' }}
|
||||
iconType="line"
|
||||
/>
|
||||
{sellers.map((seller, index) => (
|
||||
<Line
|
||||
key={seller}
|
||||
type="monotone"
|
||||
dataKey={seller}
|
||||
stroke={COLORS[index % COLORS.length]}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
connectNulls
|
||||
<LineChart data={chartData} margin={{ top: 10, right: 30, left: 10, bottom: 60 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#2a2f4a" />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
stroke="#888"
|
||||
tick={{ fill: '#888', fontSize: 10 }}
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={80}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
<YAxis
|
||||
stroke="#888"
|
||||
tick={{ fill: '#888', fontSize: 12 }}
|
||||
label={{ value: 'USD per 1M AUEC', angle: -90, position: 'insideLeft', fill: '#888' }}
|
||||
domain={yAxisDomain}
|
||||
allowDataOverflow={true}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#1a1f3a', border: '1px solid #2a2f4a', borderRadius: '4px' }}
|
||||
labelStyle={{ color: '#fff' }}
|
||||
labelFormatter={(label, payload) => {
|
||||
if (payload && payload.length > 0 && payload[0].payload.fullTime) {
|
||||
return payload[0].payload.fullTime;
|
||||
}
|
||||
return label;
|
||||
}}
|
||||
formatter={(value: any, name: string) => [`$${Number(value).toFixed(4)}`, name]}
|
||||
wrapperStyle={{ zIndex: 1000 }}
|
||||
/>
|
||||
<Legend
|
||||
wrapperStyle={{
|
||||
color: '#fff',
|
||||
maxHeight: '100px',
|
||||
overflowY: 'auto',
|
||||
zIndex: 1,
|
||||
paddingRight: '5px'
|
||||
}}
|
||||
iconType="line"
|
||||
/>
|
||||
{sellers.map((seller) => (
|
||||
<Line
|
||||
key={seller}
|
||||
type="monotone"
|
||||
dataKey={seller}
|
||||
stroke={COLORS[sellers.indexOf(seller) % COLORS.length]}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
connectNulls
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -273,8 +769,14 @@ export function App() {
|
||||
|
||||
{latestPrice && (
|
||||
<div style={{ backgroundColor: '#1a1f3a', padding: '20px', borderRadius: '8px' }}>
|
||||
<h2>Current Listings ({latestPrice.allPrices.length})</h2>
|
||||
<div style={{ overflowX: 'auto', overflowY: 'auto', maxHeight: '400px' }}>
|
||||
<h2 style={{ margin: '0 0 15px 0' }}>Current Listings ({latestPrice.allPrices.length})</h2>
|
||||
<div style={{
|
||||
overflowX: 'auto',
|
||||
overflowY: 'auto',
|
||||
maxHeight: '400px',
|
||||
border: '1px solid #2a2f4a',
|
||||
borderRadius: '4px',
|
||||
}}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead style={{ position: 'sticky', top: 0, backgroundColor: '#1a1f3a', zIndex: 1 }}>
|
||||
<tr style={{ borderBottom: '2px solid #2a2f4a' }}>
|
||||
@@ -284,7 +786,7 @@ export function App() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{latestPrice.allPrices.map((price, index) => (
|
||||
{latestPrice.allPrices.map((price) => (
|
||||
<tr key={price.id} style={{ borderBottom: '1px solid #2a2f4a' }}>
|
||||
<td style={{ padding: '12px' }}>{price.platform}</td>
|
||||
<td style={{ padding: '12px' }}>{price.sellerName}</td>
|
||||
@@ -298,6 +800,57 @@ export function App() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Alert Notification Popup */}
|
||||
{alertNotification && (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: '80px',
|
||||
right: '20px',
|
||||
backgroundColor: '#1a1f3a',
|
||||
border: '2px solid #50e3c2',
|
||||
borderRadius: '8px',
|
||||
padding: '20px',
|
||||
maxWidth: '400px',
|
||||
boxShadow: '0 4px 20px rgba(80, 227, 194, 0.4)',
|
||||
zIndex: 9999,
|
||||
animation: 'slideIn 0.3s ease-out',
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '10px' }}>
|
||||
<h3 style={{ margin: 0, color: '#50e3c2', fontSize: '18px' }}>🎯 Price Alert!</h3>
|
||||
<button
|
||||
onClick={dismissNotification}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#888',
|
||||
fontSize: '20px',
|
||||
cursor: 'pointer',
|
||||
padding: '0',
|
||||
lineHeight: '1',
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ fontSize: '16px', marginBottom: '8px', fontWeight: 'bold' }}>
|
||||
{alertNotification.message}
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#888' }}>
|
||||
Seller: <span style={{ color: '#fff' }}>{alertNotification.sellerName}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#888' }}>
|
||||
Platform: <span style={{ color: '#fff' }}>{alertNotification.platform}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Alert Audio */}
|
||||
<audio
|
||||
ref={alertAudioRef}
|
||||
src="data:audio/wav;base64,UklGRnoGAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQoGAACBhYqFbF1fdJivrJBhNjVgodDbq2EcBj+a2/LDciUFLIHO8tiJNwgZaLvt559NEAxQp+PwtmMcBjiR1/LMeSwFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBSuBzvLZiTYIGGS57OihUBELTKXh8bllHAU2jdXvz3oqBSh+zPDajzsKElyx6OyrWBUIQ5zd8sFuJAUuhM/z24k2CBhku+zooVARC0yl4fG5ZRwFNo3V7896KgUofszw2o87ChJcsevsq1gVCEOc3fLBbiQFLoTP89uJNggYZLvs6KFQEQtMpeHxuWUcBTaN1e/PeioFKH7M8NqPOwsSXLHr7KtYFQhDnN3ywW4kBS6Ez/PbiTYIGGS77OihUBELTKXh8bllHAU2jdXvz3oqBSh+zPDajzsKElyx6+yrWBUIQ5zd8sFuJAUuhM/z24k2CBhku+zooVARC0yl4fG5ZRwFNo3V7896KgUofszw2o87ChJcsevsq1gVCEOc3fLBbiQFLoTP89uJNggYZLvs6KFQEQtMpeHxuWUcBTaN1e/PeioFKH7M8NqPOwsSXLHr7KtYFQhDnN3ywW4kBS6Ez/PbiTYIGGS77OihUBELTKXh8bllHAU2jdXvz3oqBSh+zPDajzsKElyx6+yrWBUIQ5zd8sFuJAUuhM/z24k2CBhku+zooVARC0yl4fG5ZRwFNo3V7896KgUofszw2o87ChJcsevsq1gVCEOc3fLBbiQFLoTP89uJNggYZLvs6KFQEQtMpeHxuWUcBTaN1e/PeioFKH7M8NqPOwsSXLHr7KtYFQhDnN3ywW4kBS6Ez/PbiTYIGGS77OihUBELTKXh8bllHAU2jdXvz3oqBSh+zPDajzsKElyx6+yrWBUIQ5zd8sFuJAUuhM/z24k2CBhku+zooVARC0yl4fG5ZRwFNo3V7896KgUofszw2o87ChJcsevsq1gVCEOc3fLBbiQFLoTP89uJNggYZLvs6KFQEQtMpeHxuWUcBTaN1e/PeioFKH7M8NqPOwsSXLHr7KtYFQhDnN3ywW4kBS6Ez/PbiTYIGGS77OihUBELTKXh8bllHAU2jdXvz3oqBSh+zPDajzsKElyx6+yrWBUIQ5zd8sFuJAUuhM/z24k2CBhku+zooVARC0yl4fG5ZRwFNo3V7896KgUofszw2o87ChJcsevsq1gVCEOc3fLBbiQFLoTP89uJNggYZLvs6KFQEQtMpeHxuWUcBTaN1e/PeioFKH7M8NqPOwsSXLHr7KtYFQhDnN3ywW4kBS6Ez/PbiTYIGGS77OihUBELTKXh8bllHAU2jdXvz3oqBSh+zPDajzsKElyx6+yrWBUIQ5zd8sFuJAUuhM/z24k2CBhku+zooVARC0yl4fG5ZRwFNo3V7896KgUofszw2o87ChJcsevsq1gVCEOc3fLBbiQFLoTP89uJNggYZLvs6KFQEQtMpeHxuWUcBTaN1e/PeioFKH7M8NqPOwsSXLHr7KtYFQhDnN3ywW4kBS6Ez/PbiTYIGGS77OihUBELTKXh8bllHAU2jdXvz3oqBSh+zPDajzsKElyx6+yrWBUIQ5zd8sFuJAUuhM/z24k2CBhku+zooVARC0yl4fG5ZRwFNo3V7896KgUofszw2o87ChJcsevsq1gVCEOc3fLBbiQFLoTP89uJNggYZLvs6KFQEQtMpeHxuWQ="
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
173
electron-app/src/renderer/components/TitleBar.tsx
Normal file
173
electron-app/src/renderer/components/TitleBar.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export function TitleBar() {
|
||||
const [isMaximized, setIsMaximized] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let retries = 0;
|
||||
const maxRetries = 5;
|
||||
|
||||
const checkMaximized = async () => {
|
||||
try {
|
||||
const maximized = await window.electron.ipcRenderer.invoke('window:isMaximized');
|
||||
setIsMaximized(maximized);
|
||||
} catch (error) {
|
||||
if (retries < maxRetries) {
|
||||
retries++;
|
||||
setTimeout(checkMaximized, 200 * retries);
|
||||
} else {
|
||||
console.error('Failed to check maximized state after retries:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Small delay to ensure handlers are registered
|
||||
const timer = setTimeout(checkMaximized, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
const handleMinimize = () => {
|
||||
window.electron.ipcRenderer.invoke('window:minimize');
|
||||
};
|
||||
|
||||
const handleMaximize = async () => {
|
||||
try {
|
||||
await window.electron.ipcRenderer.invoke('window:maximize');
|
||||
const maximized = await window.electron.ipcRenderer.invoke('window:isMaximized');
|
||||
setIsMaximized(maximized);
|
||||
} catch (error) {
|
||||
console.error('Failed to maximize window:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
window.electron.ipcRenderer.invoke('window:close');
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
height: '40px',
|
||||
backgroundColor: '#0f1329',
|
||||
borderBottom: '1px solid #50e3c2',
|
||||
WebkitAppRegion: 'drag',
|
||||
userSelect: 'none',
|
||||
padding: '0 15px',
|
||||
} as any}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<div
|
||||
style={{
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: '#50e3c2',
|
||||
boxShadow: '0 0 8px rgba(80, 227, 194, 0.6)',
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: '14px', fontWeight: 'bold', color: '#50e3c2' }}>
|
||||
rmtPocketWatcher
|
||||
</span>
|
||||
<span style={{ fontSize: '11px', color: '#888', marginLeft: '5px' }}>
|
||||
Lambda Banking Conglomerate
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '1px',
|
||||
WebkitAppRegion: 'no-drag',
|
||||
} as any}
|
||||
>
|
||||
<button
|
||||
onClick={() => window.electron.ipcRenderer.invoke('window:hideToTray')}
|
||||
style={{
|
||||
width: '46px',
|
||||
height: '32px',
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
color: '#fff',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'background-color 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = '#2a2f4a')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = 'transparent')}
|
||||
title="Hide to Tray"
|
||||
>
|
||||
▼
|
||||
</button>
|
||||
<button
|
||||
onClick={handleMinimize}
|
||||
style={{
|
||||
width: '46px',
|
||||
height: '32px',
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
color: '#fff',
|
||||
cursor: 'pointer',
|
||||
fontSize: '16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'background-color 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = '#2a2f4a')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = 'transparent')}
|
||||
title="Minimize"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<button
|
||||
onClick={handleMaximize}
|
||||
style={{
|
||||
width: '46px',
|
||||
height: '32px',
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
color: '#fff',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'background-color 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = '#2a2f4a')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = 'transparent')}
|
||||
title={isMaximized ? 'Restore' : 'Maximize'}
|
||||
>
|
||||
{isMaximized ? '❐' : '□'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
style={{
|
||||
width: '46px',
|
||||
height: '32px',
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
color: '#fff',
|
||||
cursor: 'pointer',
|
||||
fontSize: '16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'background-color 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = '#ff6b9d')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = 'transparent')}
|
||||
title="Close"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -84,6 +84,17 @@ export function useWebSocket() {
|
||||
await sendMessage({ type: 'get_history', data: { range } });
|
||||
}, [sendMessage]);
|
||||
|
||||
const fetchInitialData = useCallback(async () => {
|
||||
try {
|
||||
const response = await window.electron.ipcRenderer.invoke('api:getLatestPrices');
|
||||
if (response.success && response.data) {
|
||||
setLatestPrice(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch initial data:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
status,
|
||||
latestPrice,
|
||||
@@ -92,5 +103,6 @@ export function useWebSocket() {
|
||||
disconnect,
|
||||
sendMessage,
|
||||
requestHistory,
|
||||
fetchInitialData,
|
||||
};
|
||||
}
|
||||
|
||||
43
electron-app/src/renderer/index.css
Normal file
43
electron-app/src/renderer/index.css
Normal file
@@ -0,0 +1,43 @@
|
||||
/* Custom Scrollbar Styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #0a0e27;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #50e3c2;
|
||||
border-radius: 5px;
|
||||
border: 2px solid #0a0e27;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #3dc9aa;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-corner {
|
||||
background: #0a0e27;
|
||||
}
|
||||
|
||||
/* Apply to all scrollable elements */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #50e3c2 #0a0e27;
|
||||
}
|
||||
|
||||
|
||||
/* Alert notification animation */
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(400px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { App } from './App';
|
||||
import './index.css';
|
||||
|
||||
const container = document.getElementById('root');
|
||||
if (!container) throw new Error('Root element not found');
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist/main",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/main/**/*", "src/shared/**/*"]
|
||||
|
||||
Reference in New Issue
Block a user