Workflows ETC
Some checks failed
Deploy to Server / deploy (push) Failing after 4s

This commit is contained in:
2025-10-25 18:03:36 -04:00
parent 39e49739c9
commit d8f3edb9fe
8 changed files with 277 additions and 12 deletions

View File

@@ -0,0 +1,41 @@
name: Deploy to Server
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Install SSH Key
run: |
mkdir -p ~/.ssh
echo "${{ secrets.DEPLOY_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan 192.168.30.114 >> ~/.ssh/known_hosts
- name: Deploy via SSH
run: |
ssh deployuser@192.168.30.114 << 'EOF'
set -euo pipefail
APP_DIR=/opt/Train-ID
SERVICE=train-id
if [ ! -d "$APP_DIR" ]; then
sudo mkdir -p "$APP_DIR"
sudo chown "$USER":"$USER" "$APP_DIR"
git clone https://git.hudsonriggs.systems/HRiggs/Train-ID.git "$APP_DIR"
fi
cd "$APP_DIR"
git pull origin main
# Install Node.js deps and build
npm ci || npm install
npm run build
# Ensure systemd service exists and restart
if systemctl list-unit-files | grep -q "${SERVICE}.service"; then
sudo systemctl restart "$SERVICE"
else
echo "Warning: ${SERVICE}.service not found; start your process manager manually."
fi
EOF

Binary file not shown.

138
README.md
View File

@@ -1,2 +1,140 @@
# Train-ID
Simple Node/Express app to identify and inventory model trains from photos using OpenAI Vision, store results in MySQL, and export PDFs/XLSX.
## Requirements
- Node.js 20+ and npm
- MySQL 8+
- OpenAI API key
## Environment variables
Create a `.env` in the project root on the server:
```
PORT=3000
# OpenAI
OPENAI_API_KEY=sk-...
# or alternative variable name also supported by code:
# openapi_key=sk-...
# MySQL connection
db_ip=127.0.0.1
db_port=3306
db_user=trainid
db_pass=changeme
db_name=trainid
```
## Local development
```
npm install
npm run build
npm start
# or for auto-reload on TypeScript build changes:
npm run dev
```
Visit `http://localhost:3000`.
## API overview
- POST `/api/upload` with `multipart/form-data` field `image` → analyzes photo, inserts record
- GET `/api/items` → list items (optional `?q=` to search model/SKU)
- GET `/api/items/:id/pdf` → generate/download PDF for one item
- GET `/api/export/xlsx` → download XLSX export of inventory with embedded thumbnails
- DELETE `/api/items/:id` → delete one
- DELETE `/api/items` → wipe all
## Debian 13 (Trixie) LXC install
These steps assume a fresh Debian 13 LXC and deployment directory `/opt/Train-ID` with a system user `deployuser` that has passwordless sudo for service management.
1) Base packages and Node.js
```
sudo apt update
sudo apt install -y curl ca-certificates gnupg build-essential pkg-config
# Node 20 using Nodesource or Debian repo (choose one). Nodesource example:
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install -y nodejs
node -v && npm -v
```
2) MySQL server (or connect to external MySQL)
```
sudo apt install -y mariadb-server mariadb-client
sudo systemctl enable --now mariadb
sudo mysql -e "CREATE DATABASE IF NOT EXISTS trainid CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
sudo mysql -e "CREATE USER IF NOT EXISTS 'trainid'@'%' IDENTIFIED BY 'changeme';"
sudo mysql -e "GRANT ALL PRIVILEGES ON trainid.* TO 'trainid'@'%'; FLUSH PRIVILEGES;"
```
If using external MySQL, skip install and set `db_ip` etc. in `.env`.
3) App checkout and build
```
sudo mkdir -p /opt/Train-ID
sudo chown $USER:$USER /opt/Train-ID
git clone https://git.hudsonriggs.systems/HRiggs/Train-ID.git /opt/Train-ID
cd /opt/Train-ID
cp .env.example .env || true # if you keep a template in the future
# create .env as per above
npm ci || npm install
npm run build
```
4) Systemd service
Create `/etc/systemd/system/train-id.service`:
```
[Unit]
Description=Train-ID API
After=network.target
[Service]
Type=simple
WorkingDirectory=/opt/Train-ID
EnvironmentFile=/opt/Train-ID/.env
ExecStart=/usr/bin/node /opt/Train-ID/dist/server.js
Restart=always
RestartSec=5
User=www-data
Group=www-data
[Install]
WantedBy=multi-user.target
```
Then enable and start:
```
sudo systemctl daemon-reload
sudo systemctl enable --now train-id
sudo systemctl status train-id --no-pager
```
5) Reverse proxy (optional, for port 80/443)
Example Nginx site (`/etc/nginx/sites-available/train-id`):
```
server {
listen 80;
server_name _;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
Enable and reload:
```
sudo apt install -y nginx
sudo ln -s /etc/nginx/sites-available/train-id /etc/nginx/sites-enabled/train-id
sudo nginx -t && sudo systemctl reload nginx
```
## CI/CD deploy
The repo includes `.gitea/workflows/deploy.yaml` which, on push to `main`, SSHes to `192.168.30.114`, pulls latest, installs dependencies, builds, and restarts the `train-id` service. Set `DEPLOY_KEY` in repository secrets to a private SSH key authorized for `deployuser@192.168.30.114`.
If your service name or directory differ, update `SERVICE`/`APP_DIR` in the workflow accordingly.
```
echo "deployuser ALL=(root) NOPASSWD: /bin/systemctl restart train-id.service" | sudo tee /etc/sudoers.d/train-id >/dev/null
sudo chmod 440 /etc/sudoers.d/train-id
sudo visudo -cf /etc/sudoers.d/train-id
```

BIN
image.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 212 KiB

View File

@@ -22,13 +22,20 @@ async function ensureSchema() {
description text not null,
item_condition varchar(50) not null,
has_box boolean not null,
image_path text not null,
image_path text null,
image_data longblob null,
image_mime varchar(100) null,
created_at timestamp default current_timestamp
) engine=InnoDB;
`;
await pool.query(sql);
// backfill for older tables without sku
await pool.query(`alter table items add column if not exists sku varchar(64) null` as any);
// backfill nullable image_path
await pool.query(`alter table items modify column image_path text null` as any);
// add image_data and image_mime if missing
await pool.query(`alter table items add column if not exists image_data longblob null` as any);
await pool.query(`alter table items add column if not exists image_mime varchar(100) null` as any);
}
let schemaEnsured: Promise<void> | null = null;
@@ -41,9 +48,20 @@ export const db = {
async insertItem(item: Omit<TrainItem, 'id'>) {
await getReady();
const [result] = await pool.execute(
`insert into items (manufacturer, model, sku, quantity, description, item_condition, has_box, image_path)
values (?,?,?,?,?,?,?,?)`,
[item.manufacturer, item.model, item.sku || null, item.quantity, item.description, item.condition, item.hasBox, item.imagePath]
`insert into items (manufacturer, model, sku, quantity, description, item_condition, has_box, image_path, image_data, image_mime)
values (?,?,?,?,?,?,?,?,?,?)`,
[
item.manufacturer,
item.model,
item.sku || null,
item.quantity,
item.description,
item.condition,
item.hasBox,
item.imagePath || null,
(item as any).imageData || null,
(item as any).imageMime || null
]
);
const insertId = (result as any).insertId as number;
const [rows] = await pool.query(
@@ -79,6 +97,27 @@ export const db = {
if (row) { row.condition = row.cond; delete row.cond; }
return row;
},
async getItemWithImage(id: number) {
await getReady();
const [rows] = await pool.query(
`select id, manufacturer, model, sku, quantity, description, item_condition as cond, has_box as hasBox, image_path as imagePath, image_data as imageData, image_mime as imageMime, created_at as createdAt
from items where id = ?`,
[id]
);
const row = (rows as any[])[0] || null;
if (row) { row.condition = row.cond; delete row.cond; }
return row;
},
async listItemsWithImage() {
await getReady();
const [rows] = await pool.query(
`select id, manufacturer, model, sku, quantity, description, item_condition as cond, has_box as hasBox, image_path as imagePath, image_data as imageData, image_mime as imageMime, created_at as createdAt
from items order by id asc`
);
const arr = rows as any[];
for (const r of arr) { r.condition = r.cond; delete r.cond; }
return arr;
},
async deleteItem(id: number) {
await getReady();
const [res] = await pool.query('delete from items where id = ?', [id]);

View File

@@ -21,6 +21,10 @@ router.post('/upload', async (req, res) => {
const analysis = await analyzeImageToMetadata(savePath);
const imageBuffer = await fs.readFile(savePath);
const imageMime: string = file.mimetype || 'application/octet-stream';
// Insert with image bytes and mime; do not persist path
const saved = await db.insertItem({
manufacturer: analysis.manufacturer,
model: analysis.model,
@@ -29,8 +33,13 @@ router.post('/upload', async (req, res) => {
description: analysis.description,
condition: analysis.condition,
hasBox: analysis.hasBox,
imagePath: savePath,
});
imagePath: null as any,
imageData: imageBuffer as any,
imageMime: imageMime as any
} as any);
// Remove uploaded file after DB storage
try { await fs.unlink(savePath); } catch {}
res.json({ item: saved });
} catch (err: any) {
@@ -53,7 +62,7 @@ router.get('/items', async (req, res) => {
router.get('/items/:id/pdf', async (req, res) => {
try {
const id = Number(req.params.id);
const item = await db.getItem(id);
const item = await db.getItemWithImage(id);
if (!item) return res.status(404).json({ error: 'Not found' });
const pdfPath = await generatePdfForItem(item);
res.setHeader('Content-Type', 'application/pdf');
@@ -79,11 +88,41 @@ router.get('/export/xlsx', async (_req, res) => {
{ header: 'Condition', key: 'condition', width: 14 },
{ header: 'Description', key: 'description', width: 50 },
{ header: 'Has Box', key: 'hasBox', width: 10 },
{ header: 'Image Path', key: 'imagePath', width: 50 },
{ header: 'Image', key: 'image', width: 25 },
{ header: 'Created', key: 'createdAt', width: 24 }
];
const items = await db.listItems();
for (const it of items) ws.addRow(it);
const items = await db.listItemsWithImage();
let rowIndex = 2; // 1-based, row 1 is header
for (const it of items) {
// Add row without imagePath, we have an Image column placeholder
const row = ws.addRow({
id: it.id,
manufacturer: it.manufacturer,
model: it.model,
sku: it.sku,
quantity: it.quantity,
condition: it.condition,
description: it.description,
hasBox: it.hasBox ? 'Yes' : 'No',
image: '',
createdAt: it.createdAt
});
// Adjust row height for image display
row.height = 90;
// Embed image if available
const buf = (it as any).imageData as unknown as Uint8Array | null;
const mime: string | null = (it as any).imageMime || null;
if (buf && mime && (mime.includes('png') || mime.includes('jpeg') || mime.includes('jpg'))) {
const imgId = wb.addImage({ buffer: Buffer.from(buf as any) as any, extension: mime.includes('png') ? 'png' : 'jpeg' });
// Place image in the Image column (index 9, zero-based col 8) anchored to this row
const colIndexZeroBased = 8; // 0-based; column I (Image) since A=0
ws.addImage(imgId, {
tl: { col: colIndexZeroBased, row: rowIndex - 1 },
ext: { width: 120, height: 80 }
});
}
rowIndex++;
}
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.setHeader('Content-Disposition', 'attachment; filename="train_inventory.xlsx"');
await wb.xlsx.write(res);

View File

@@ -11,7 +11,10 @@ export type TrainItem = {
description: string; // 1 sentence
condition: string; // e.g., New, Excellent, Good, Fair, Poor
hasBox: boolean;
imagePath: string;
// Prefer in-DB storage; path retained for legacy/backfill
imagePath?: string;
imageData?: Buffer;
imageMime?: string;
id?: number;
};

View File

@@ -39,7 +39,12 @@ export async function generatePdfForItem(item: TrainItem & { id: number }): Prom
try {
doc.moveDown();
doc.text('Photo:', { continued: false });
const imageBuffer: Buffer | undefined = (item as any).imageData;
if (imageBuffer && imageBuffer.length > 0) {
doc.image(imageBuffer, { width: 300 });
} else if (item.imagePath) {
doc.image(item.imagePath, { width: 300 });
}
} catch {}
doc.end();