@@ -1,9 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<extensions>
|
|
||||||
<!-- Override Maven's internal Guice with a Java 25 compatible version -->
|
|
||||||
<extension>
|
|
||||||
<groupId>com.google.inject</groupId>
|
|
||||||
<artifactId>guice</artifactId>
|
|
||||||
<version>7.0.0</version>
|
|
||||||
</extension>
|
|
||||||
</extensions>
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
--add-opens=java.base/java.lang=ALL-UNNAMED
|
|
||||||
--add-opens=java.base/jdk.internal.misc=ALL-UNNAMED
|
|
||||||
--add-exports=java.base/sun.nio.ch=ALL-UNNAMED
|
|
||||||
-Djdk.module.illegalAccess=deny
|
|
||||||
-XX:+IgnoreUnrecognizedVMOptions
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright 2007-present the original author or authors.
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
import java.net.*;
|
|
||||||
import java.io.*;
|
|
||||||
import java.nio.channels.*;
|
|
||||||
import java.util.Properties;
|
|
||||||
|
|
||||||
public class MavenWrapperDownloader {
|
|
||||||
|
|
||||||
private static final String WRAPPER_VERSION = "0.5.6";
|
|
||||||
/**
|
|
||||||
* Default URL to download the maven-wrapper.jar from, if no 'downloadUrl' is provided.
|
|
||||||
*/
|
|
||||||
private static final String DEFAULT_DOWNLOAD_URL = "https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/"
|
|
||||||
+ WRAPPER_VERSION + "/maven-wrapper-" + WRAPPER_VERSION + ".jar";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Path to the maven-wrapper.properties file, which might contain a downloadUrl property to
|
|
||||||
* use instead of the default one.
|
|
||||||
*/
|
|
||||||
private static final String MAVEN_WRAPPER_PROPERTIES_PATH =
|
|
||||||
".mvn/wrapper/maven-wrapper.properties";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Path where the maven-wrapper.jar will be saved to.
|
|
||||||
*/
|
|
||||||
private static final String MAVEN_WRAPPER_JAR_PATH =
|
|
||||||
".mvn/wrapper/maven-wrapper.jar";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Name of the property which should be used to override the default download url for the wrapper.
|
|
||||||
*/
|
|
||||||
private static final String PROPERTY_NAME_WRAPPER_URL = "wrapperUrl";
|
|
||||||
|
|
||||||
public static void main(String args[]) {
|
|
||||||
System.out.println("- Downloader started");
|
|
||||||
File baseDirectory = new File(args[0]);
|
|
||||||
System.out.println("- Using base directory: " + baseDirectory.getAbsolutePath());
|
|
||||||
|
|
||||||
// If the maven-wrapper.properties exists, read it and check if it contains a custom
|
|
||||||
// wrapperUrl parameter.
|
|
||||||
File mavenWrapperPropertyFile = new File(baseDirectory, MAVEN_WRAPPER_PROPERTIES_PATH);
|
|
||||||
String url = DEFAULT_DOWNLOAD_URL;
|
|
||||||
if(mavenWrapperPropertyFile.exists()) {
|
|
||||||
FileInputStream mavenWrapperPropertyFileInputStream = null;
|
|
||||||
try {
|
|
||||||
mavenWrapperPropertyFileInputStream = new FileInputStream(mavenWrapperPropertyFile);
|
|
||||||
Properties mavenWrapperProperties = new Properties();
|
|
||||||
mavenWrapperProperties.load(mavenWrapperPropertyFileInputStream);
|
|
||||||
url = mavenWrapperProperties.getProperty(PROPERTY_NAME_WRAPPER_URL, url);
|
|
||||||
} catch (IOException e) {
|
|
||||||
System.out.println("- ERROR loading '" + MAVEN_WRAPPER_PROPERTIES_PATH + "'");
|
|
||||||
} finally {
|
|
||||||
try {
|
|
||||||
if(mavenWrapperPropertyFileInputStream != null) {
|
|
||||||
mavenWrapperPropertyFileInputStream.close();
|
|
||||||
}
|
|
||||||
} catch (IOException e) {
|
|
||||||
// Ignore ...
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
System.out.println("- Downloading from: " + url);
|
|
||||||
|
|
||||||
File outputFile = new File(baseDirectory.getAbsolutePath(), MAVEN_WRAPPER_JAR_PATH);
|
|
||||||
if(!outputFile.getParentFile().exists()) {
|
|
||||||
if(!outputFile.getParentFile().mkdirs()) {
|
|
||||||
System.out.println(
|
|
||||||
"- ERROR creating output directory '" + outputFile.getParentFile().getAbsolutePath() + "'");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
System.out.println("- Downloading to: " + outputFile.getAbsolutePath());
|
|
||||||
try {
|
|
||||||
downloadFileFromURL(url, outputFile);
|
|
||||||
System.out.println("Done");
|
|
||||||
System.exit(0);
|
|
||||||
} catch (Throwable e) {
|
|
||||||
System.out.println("- Error downloading");
|
|
||||||
e.printStackTrace();
|
|
||||||
System.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void downloadFileFromURL(String urlString, File destination) throws Exception {
|
|
||||||
if (System.getenv("MVNW_USERNAME") != null && System.getenv("MVNW_PASSWORD") != null) {
|
|
||||||
String username = System.getenv("MVNW_USERNAME");
|
|
||||||
char[] password = System.getenv("MVNW_PASSWORD").toCharArray();
|
|
||||||
Authenticator.setDefault(new Authenticator() {
|
|
||||||
@Override
|
|
||||||
protected PasswordAuthentication getPasswordAuthentication() {
|
|
||||||
return new PasswordAuthentication(username, password);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
URL website = new URL(urlString);
|
|
||||||
ReadableByteChannel rbc;
|
|
||||||
rbc = Channels.newChannel(website.openStream());
|
|
||||||
FileOutputStream fos = new FileOutputStream(destination);
|
|
||||||
fos.getChannel().transferFrom(rbc, 0, Long.MAX_VALUE);
|
|
||||||
fos.close();
|
|
||||||
rbc.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
Binary file not shown.
@@ -1,2 +0,0 @@
|
|||||||
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3/apache-maven-3-bin.zip
|
|
||||||
wrapperUrl=https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar
|
|
||||||
192
docs/DATABASE_CLEANUP_GUIDE.md
Normal file
192
docs/DATABASE_CLEANUP_GUIDE.md
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
# Database Cleanup Guide
|
||||||
|
|
||||||
|
## Problem: Mixed Data Formats
|
||||||
|
|
||||||
|
Your production database (`cache.db`) contains data from two different scrapers:
|
||||||
|
|
||||||
|
### Valid Data (99.92%)
|
||||||
|
- **Format**: `A1-34732-49` (lot_id) + `c1f44ec2-ad6e-4c98-b0e2-cb1d8ccddcab` (auction_id UUID)
|
||||||
|
- **Count**: 16,794 lots
|
||||||
|
- **Source**: Current GraphQL-based scraper
|
||||||
|
- **Status**: ✅ Clean, with proper auction_id
|
||||||
|
|
||||||
|
### Invalid Data (0.08%)
|
||||||
|
- **Format**: `bmw-550i-4-4-v8-high-executive-...` (slug as lot_id) + `""` (empty auction_id)
|
||||||
|
- **Count**: 13 lots
|
||||||
|
- **Source**: Old legacy scraper
|
||||||
|
- **Status**: ❌ Missing auction_id, causes issues
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
These 13 invalid entries:
|
||||||
|
- Cause `NullPointerException` in analytics when grouping by country
|
||||||
|
- Cannot be properly linked to auctions
|
||||||
|
- Skew statistics slightly
|
||||||
|
- May cause issues with intelligence features that rely on auction_id
|
||||||
|
|
||||||
|
## Solution 1: Clean Sync (Recommended)
|
||||||
|
|
||||||
|
The updated sync script now **automatically removes old local data** before syncing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Windows PowerShell
|
||||||
|
.\scripts\Sync-ProductionData.ps1
|
||||||
|
|
||||||
|
# Linux/Mac
|
||||||
|
./scripts/sync-production-data.sh --db-only
|
||||||
|
```
|
||||||
|
|
||||||
|
**What it does**:
|
||||||
|
1. Backs up existing database to `cache.db.backup-YYYYMMDD-HHMMSS`
|
||||||
|
2. **Removes old local database completely**
|
||||||
|
3. Downloads fresh copy from production
|
||||||
|
4. Shows data quality report
|
||||||
|
|
||||||
|
**Output includes**:
|
||||||
|
```
|
||||||
|
Database statistics:
|
||||||
|
┌─────────────┬────────┐
|
||||||
|
│ table_name │ count │
|
||||||
|
├─────────────┼────────┤
|
||||||
|
│ auctions │ 526 │
|
||||||
|
│ lots │ 16807 │
|
||||||
|
│ images │ 536502 │
|
||||||
|
│ cache │ 2134 │
|
||||||
|
└─────────────┴────────┘
|
||||||
|
|
||||||
|
Data quality:
|
||||||
|
┌────────────────────────────────────┬────────┬────────────┐
|
||||||
|
│ metric │ count │ percentage │
|
||||||
|
├────────────────────────────────────┼────────┼────────────┤
|
||||||
|
│ Valid lots │ 16794 │ 99.92% │
|
||||||
|
│ Invalid lots (missing auction_id) │ 13 │ 0.08% │
|
||||||
|
│ Lots with intelligence fields │ 0 │ 0.00% │
|
||||||
|
└────────────────────────────────────┴────────┴────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Solution 2: Manual Cleanup
|
||||||
|
|
||||||
|
If you want to clean your existing local database without re-downloading:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Dry run (see what would be deleted)
|
||||||
|
./scripts/cleanup-database.sh --dry-run
|
||||||
|
|
||||||
|
# Actual cleanup
|
||||||
|
./scripts/cleanup-database.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
**What it does**:
|
||||||
|
1. Creates backup before cleanup
|
||||||
|
2. Deletes lots with missing auction_id
|
||||||
|
3. Deletes orphaned images (images without matching lots)
|
||||||
|
4. Compacts database (VACUUM) to reclaim space
|
||||||
|
5. Shows before/after statistics
|
||||||
|
|
||||||
|
**Example output**:
|
||||||
|
```
|
||||||
|
Current database state:
|
||||||
|
┌──────────────────────────────────┬────────┐
|
||||||
|
│ metric │ count │
|
||||||
|
├──────────────────────────────────┼────────┤
|
||||||
|
│ Total lots │ 16807 │
|
||||||
|
│ Valid lots (with auction_id) │ 16794 │
|
||||||
|
│ Invalid lots (missing auction_id) │ 13 │
|
||||||
|
└──────────────────────────────────┴────────┘
|
||||||
|
|
||||||
|
Analyzing data to clean up...
|
||||||
|
→ Invalid lots to delete: 13
|
||||||
|
→ Orphaned images to delete: 0
|
||||||
|
|
||||||
|
This will permanently delete the above records.
|
||||||
|
Continue? (y/N) y
|
||||||
|
|
||||||
|
Cleaning up database...
|
||||||
|
[1/2] Deleting invalid lots...
|
||||||
|
✓ Deleted 13 invalid lots
|
||||||
|
[2/2] Deleting orphaned images...
|
||||||
|
✓ Deleted 0 orphaned images
|
||||||
|
[3/3] Compacting database...
|
||||||
|
✓ Database compacted
|
||||||
|
|
||||||
|
Final database state:
|
||||||
|
┌───────────────┬────────┐
|
||||||
|
│ metric │ count │
|
||||||
|
├───────────────┼────────┤
|
||||||
|
│ Total lots │ 16794 │
|
||||||
|
│ Total images │ 536502 │
|
||||||
|
└───────────────┴────────┘
|
||||||
|
|
||||||
|
Database size: 8.9G
|
||||||
|
```
|
||||||
|
|
||||||
|
## Solution 3: SQL Manual Cleanup
|
||||||
|
|
||||||
|
If you prefer to manually clean using SQL:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Backup first!
|
||||||
|
-- cp cache.db cache.db.backup
|
||||||
|
|
||||||
|
-- Check invalid entries
|
||||||
|
SELECT COUNT(*), 'Invalid' as type
|
||||||
|
FROM lots
|
||||||
|
WHERE auction_id IS NULL OR auction_id = ''
|
||||||
|
UNION ALL
|
||||||
|
SELECT COUNT(*), 'Valid'
|
||||||
|
FROM lots
|
||||||
|
WHERE auction_id IS NOT NULL AND auction_id != '';
|
||||||
|
|
||||||
|
-- Delete invalid lots
|
||||||
|
DELETE FROM lots
|
||||||
|
WHERE auction_id IS NULL OR auction_id = '';
|
||||||
|
|
||||||
|
-- Delete orphaned images
|
||||||
|
DELETE FROM images
|
||||||
|
WHERE lot_id NOT IN (SELECT lot_id FROM lots);
|
||||||
|
|
||||||
|
-- Compact database
|
||||||
|
VACUUM;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prevention: Production Database Cleanup
|
||||||
|
|
||||||
|
To prevent these invalid entries from accumulating on production, you can:
|
||||||
|
|
||||||
|
1. **Clean production database** (one-time):
|
||||||
|
```bash
|
||||||
|
ssh tour@athena.lan
|
||||||
|
docker run --rm -v shared-auction-data:/data alpine sqlite3 /data/cache.db "DELETE FROM lots WHERE auction_id IS NULL OR auction_id = '';"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Update scraper** to ensure all lots have auction_id
|
||||||
|
3. **Add validation** in scraper to reject lots without auction_id
|
||||||
|
|
||||||
|
## When to Clean
|
||||||
|
|
||||||
|
### Immediately if:
|
||||||
|
- ❌ Seeing `NullPointerException` in analytics
|
||||||
|
- ❌ Dashboard insights failing
|
||||||
|
- ❌ Country distribution not working
|
||||||
|
|
||||||
|
### Periodically:
|
||||||
|
- 🔄 After syncing from production (if production has invalid data)
|
||||||
|
- 🔄 Weekly/monthly maintenance
|
||||||
|
- 🔄 Before major testing or demos
|
||||||
|
|
||||||
|
## Recommendation
|
||||||
|
|
||||||
|
**Use Solution 1 (Clean Sync)** for simplicity:
|
||||||
|
- ✅ Guarantees clean state
|
||||||
|
- ✅ No manual SQL needed
|
||||||
|
- ✅ Shows data quality report
|
||||||
|
- ✅ Safe (automatic backup)
|
||||||
|
|
||||||
|
The 13 invalid entries are from an old scraper and represent only 0.08% of data, so cleaning them up has minimal impact but prevents future errors.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Related Documentation**:
|
||||||
|
- [Sync Scripts README](../scripts/README.md)
|
||||||
|
- [Data Sync Setup](DATA_SYNC_SETUP.md)
|
||||||
|
- [Database Architecture](../wiki/DATABASE_ARCHITECTURE.md)
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
@echo off
|
|
||||||
REM ============================================================================
|
|
||||||
REM Troostwijk Auction Monitor - Status Check (Windows)
|
|
||||||
REM ============================================================================
|
|
||||||
REM
|
|
||||||
REM This script shows the current status and exits.
|
|
||||||
REM
|
|
||||||
REM Usage:
|
|
||||||
REM check-status.bat
|
|
||||||
REM
|
|
||||||
REM ============================================================================
|
|
||||||
|
|
||||||
REM Set configuration
|
|
||||||
set DATABASE_FILE=C:\mnt\okcomputer\output\cache.db
|
|
||||||
set NOTIFICATION_CONFIG=desktop
|
|
||||||
|
|
||||||
REM Check status
|
|
||||||
java -jar target\auctiora-1.0-SNAPSHOT-jar-with-dependencies.jar status
|
|
||||||
|
|
||||||
pause
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
@echo off
|
|
||||||
if "%1"=="" (
|
|
||||||
echo Error: Commit message is required
|
|
||||||
echo Usage: persist "Your commit message"
|
|
||||||
echo Example: persist "Fixed login bug"
|
|
||||||
exit /b 1
|
|
||||||
)
|
|
||||||
|
|
||||||
echo Adding all changes...
|
|
||||||
git add .
|
|
||||||
if errorlevel 1 (
|
|
||||||
echo Error: Failed to add changes
|
|
||||||
exit /b 1
|
|
||||||
)
|
|
||||||
|
|
||||||
echo Committing with message: "%*"
|
|
||||||
git commit -a -m "%*"
|
|
||||||
if errorlevel 1 (
|
|
||||||
echo Error: Failed to commit
|
|
||||||
exit /b 1
|
|
||||||
)
|
|
||||||
|
|
||||||
echo Pushing to remote...
|
|
||||||
git push
|
|
||||||
if errorlevel 1 (
|
|
||||||
echo Error: Failed to push
|
|
||||||
exit /b 1
|
|
||||||
)
|
|
||||||
|
|
||||||
echo All operations completed successfully!
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
@echo off
|
|
||||||
REM ============================================================================
|
|
||||||
REM Troostwijk Auction Monitor - Run Once (Windows)
|
|
||||||
REM ============================================================================
|
|
||||||
REM
|
|
||||||
REM This script runs the complete workflow once and exits.
|
|
||||||
REM Perfect for Windows Task Scheduler.
|
|
||||||
REM
|
|
||||||
REM Usage:
|
|
||||||
REM run-once.bat
|
|
||||||
REM
|
|
||||||
REM Schedule in Task Scheduler:
|
|
||||||
REM - Every 30 minutes: Data import
|
|
||||||
REM - Every 1 hour: Image processing
|
|
||||||
REM - Every 15 minutes: Bid monitoring
|
|
||||||
REM
|
|
||||||
REM ============================================================================
|
|
||||||
|
|
||||||
REM Set configuration
|
|
||||||
set DATABASE_FILE=C:\mnt\okcomputer\output\cache.db
|
|
||||||
set NOTIFICATION_CONFIG=desktop
|
|
||||||
|
|
||||||
REM Run the application once
|
|
||||||
java -jar target\troostwijk-scraper-1.0-SNAPSHOT-jar-with-dependencies.jar once
|
|
||||||
|
|
||||||
REM Exit code for Task Scheduler
|
|
||||||
exit /b %ERRORLEVEL%
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
@echo off
|
|
||||||
REM ============================================================================
|
|
||||||
REM Troostwijk Auction Monitor - Workflow Runner (Windows)
|
|
||||||
REM ============================================================================
|
|
||||||
REM
|
|
||||||
REM This script runs the auction monitor in workflow mode (continuous operation)
|
|
||||||
REM with all scheduled tasks running automatically.
|
|
||||||
REM
|
|
||||||
REM Usage:
|
|
||||||
REM run-workflow.bat
|
|
||||||
REM
|
|
||||||
REM ============================================================================
|
|
||||||
|
|
||||||
echo Starting Troostwijk Auction Monitor - Workflow Mode...
|
|
||||||
echo.
|
|
||||||
|
|
||||||
REM Set configuration
|
|
||||||
set DATABASE_FILE=C:\mnt\okcomputer\output\cache.db
|
|
||||||
set NOTIFICATION_CONFIG=desktop
|
|
||||||
|
|
||||||
REM Optional: Set for email notifications
|
|
||||||
REM set NOTIFICATION_CONFIG=smtp:your@gmail.com:app_password:your@gmail.com
|
|
||||||
|
|
||||||
REM Run the application
|
|
||||||
java -jar target\auctiora-1.0-SNAPSHOT-jar-with-dependencies.jar workflow
|
|
||||||
|
|
||||||
pause
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
# ============================================================================
|
|
||||||
# Troostwijk Auction Monitor - Windows Task Scheduler Setup
|
|
||||||
# ============================================================================
|
|
||||||
#
|
|
||||||
# This PowerShell script creates scheduled tasks in Windows Task Scheduler
|
|
||||||
# to run the auction monitor automatically.
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# Run PowerShell as Administrator, then:
|
|
||||||
# .\setup-windows-task.ps1
|
|
||||||
#
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
Write-Host "=== Auctiora Monitor - Task Scheduler Setup ===" -ForegroundColor Cyan
|
|
||||||
Write-Host ""
|
|
||||||
|
|
||||||
# Configuration
|
|
||||||
$scriptPath = $PSScriptRoot
|
|
||||||
$jarPath = Join-Path $scriptPath "target\auctiora-1.0-SNAPSHOT-jar-with-dependencies.jar"
|
|
||||||
$javaExe = "java"
|
|
||||||
|
|
||||||
# Check if JAR exists
|
|
||||||
if (-not (Test-Path $jarPath)) {
|
|
||||||
Write-Host "ERROR: JAR file not found at: $jarPath" -ForegroundColor Red
|
|
||||||
Write-Host "Please run 'mvn clean package' first." -ForegroundColor Yellow
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host "Creating scheduled tasks..." -ForegroundColor Green
|
|
||||||
Write-Host ""
|
|
||||||
|
|
||||||
# Task 1: Complete Workflow - Every 30 minutes
|
|
||||||
$task1Name = "TroostwijkMonitor-Workflow"
|
|
||||||
$task1Description = "Runs complete auction monitoring workflow every 30 minutes"
|
|
||||||
$task1Action = New-ScheduledTaskAction -Execute $javaExe -Argument "-jar `"$jarPath`" once" -WorkingDirectory $scriptPath
|
|
||||||
$task1Trigger = New-ScheduledTaskTrigger -Once -At (Get-Date) -RepetitionInterval (New-TimeSpan -Minutes 30) -RepetitionDuration ([TimeSpan]::MaxValue)
|
|
||||||
$task1Settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable
|
|
||||||
|
|
||||||
try {
|
|
||||||
Register-ScheduledTask -TaskName $task1Name -Action $task1Action -Trigger $task1Trigger -Settings $task1Settings -Description $task1Description -Force
|
|
||||||
Write-Host "[✓] Created task: $task1Name (every 30 min)" -ForegroundColor Green
|
|
||||||
} catch {
|
|
||||||
Write-Host "[✗] Failed to create task: $task1Name" -ForegroundColor Red
|
|
||||||
Write-Host " Error: $_" -ForegroundColor Yellow
|
|
||||||
}
|
|
||||||
|
|
||||||
# Task 2: Status Check - Every 6 hours
|
|
||||||
$task2Name = "TroostwijkMonitor-StatusCheck"
|
|
||||||
$task2Description = "Checks auction monitoring status every 6 hours"
|
|
||||||
$task2Action = New-ScheduledTaskAction -Execute $javaExe -Argument "-jar `"$jarPath`" status" -WorkingDirectory $scriptPath
|
|
||||||
$task2Trigger = New-ScheduledTaskTrigger -Once -At (Get-Date) -RepetitionInterval (New-TimeSpan -Hours 6) -RepetitionDuration ([TimeSpan]::MaxValue)
|
|
||||||
$task2Settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable
|
|
||||||
|
|
||||||
try {
|
|
||||||
Register-ScheduledTask -TaskName $task2Name -Action $task2Action -Trigger $task2Trigger -Settings $task2Settings -Description $task2Description -Force
|
|
||||||
Write-Host "[✓] Created task: $task2Name (every 6 hours)" -ForegroundColor Green
|
|
||||||
} catch {
|
|
||||||
Write-Host "[✗] Failed to create task: $task2Name" -ForegroundColor Red
|
|
||||||
Write-Host " Error: $_" -ForegroundColor Yellow
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host "=== Setup Complete ===" -ForegroundColor Cyan
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host "Created tasks:" -ForegroundColor White
|
|
||||||
Write-Host " 1. $task1Name - Runs every 30 minutes" -ForegroundColor Gray
|
|
||||||
Write-Host " 2. $task2Name - Runs every 6 hours" -ForegroundColor Gray
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host "To view tasks: Open Task Scheduler and look for 'TroostwijkMonitor-*'" -ForegroundColor Yellow
|
|
||||||
Write-Host "To remove tasks: Run 'Unregister-ScheduledTask -TaskName TroostwijkMonitor-*'" -ForegroundColor Yellow
|
|
||||||
Write-Host ""
|
|
||||||
160
scripts/cleanup-database.sh
Normal file
160
scripts/cleanup-database.sh
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# Database Cleanup Utility
|
||||||
|
#
|
||||||
|
# Removes invalid/old data from the local database
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./scripts/cleanup-database.sh [--dry-run]
|
||||||
|
#
|
||||||
|
# Options:
|
||||||
|
# --dry-run Show what would be deleted without actually deleting
|
||||||
|
#
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
LOCAL_DB_PATH="${1:-c:/mnt/okcomputer/cache.db}"
|
||||||
|
DRY_RUN=false
|
||||||
|
|
||||||
|
# Colors
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
RED='\033[0;31m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
# Parse arguments
|
||||||
|
if [ "$1" = "--dry-run" ] || [ "$2" = "--dry-run" ]; then
|
||||||
|
DRY_RUN=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$1" = "--help" ] || [ "$1" = "-h" ]; then
|
||||||
|
grep '^#' "$0" | sed 's/^# \?//'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${BLUE}╔════════════════════════════════════════════════════════╗${NC}"
|
||||||
|
echo -e "${BLUE}║ Database Cleanup - Auctiora Monitor ║${NC}"
|
||||||
|
echo -e "${BLUE}╚════════════════════════════════════════════════════════╝${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [ ! -f "${LOCAL_DB_PATH}" ]; then
|
||||||
|
echo -e "${RED}Error: Database not found at ${LOCAL_DB_PATH}${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Backup database before cleanup
|
||||||
|
if [ "$DRY_RUN" = false ]; then
|
||||||
|
BACKUP_PATH="${LOCAL_DB_PATH}.backup-before-cleanup-$(date +%Y%m%d-%H%M%S)"
|
||||||
|
echo -e "${YELLOW}Creating backup: ${BACKUP_PATH}${NC}"
|
||||||
|
cp "${LOCAL_DB_PATH}" "${BACKUP_PATH}"
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Show current state
|
||||||
|
echo -e "${BLUE}Current database state:${NC}"
|
||||||
|
sqlite3 "${LOCAL_DB_PATH}" <<EOF
|
||||||
|
.mode box
|
||||||
|
SELECT
|
||||||
|
'Total lots' as metric,
|
||||||
|
COUNT(*) as count
|
||||||
|
FROM lots
|
||||||
|
UNION ALL
|
||||||
|
SELECT
|
||||||
|
'Valid lots (with auction_id)',
|
||||||
|
COUNT(*)
|
||||||
|
FROM lots
|
||||||
|
WHERE auction_id IS NOT NULL AND auction_id != ''
|
||||||
|
UNION ALL
|
||||||
|
SELECT
|
||||||
|
'Invalid lots (missing auction_id)',
|
||||||
|
COUNT(*)
|
||||||
|
FROM lots
|
||||||
|
WHERE auction_id IS NULL OR auction_id = '';
|
||||||
|
EOF
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Count items to be deleted
|
||||||
|
echo -e "${YELLOW}Analyzing data to clean up...${NC}"
|
||||||
|
|
||||||
|
INVALID_LOTS=$(sqlite3 "${LOCAL_DB_PATH}" "SELECT COUNT(*) FROM lots WHERE auction_id IS NULL OR auction_id = '';")
|
||||||
|
ORPHANED_IMAGES=$(sqlite3 "${LOCAL_DB_PATH}" "SELECT COUNT(*) FROM images WHERE lot_id NOT IN (SELECT lot_id FROM lots);")
|
||||||
|
|
||||||
|
echo -e " ${RED}→ Invalid lots to delete: ${INVALID_LOTS}${NC}"
|
||||||
|
echo -e " ${YELLOW}→ Orphaned images to delete: ${ORPHANED_IMAGES}${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [ "$INVALID_LOTS" -eq 0 ] && [ "$ORPHANED_IMAGES" -eq 0 ]; then
|
||||||
|
echo -e "${GREEN}✓ Database is clean! No cleanup needed.${NC}"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$DRY_RUN" = true ]; then
|
||||||
|
echo -e "${BLUE}DRY RUN MODE - No changes will be made${NC}"
|
||||||
|
echo ""
|
||||||
|
echo "Would delete:"
|
||||||
|
echo " - $INVALID_LOTS invalid lots"
|
||||||
|
echo " - $ORPHANED_IMAGES orphaned images"
|
||||||
|
echo ""
|
||||||
|
echo "Run without --dry-run to perform cleanup"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Confirm cleanup
|
||||||
|
echo -e "${YELLOW}This will permanently delete the above records.${NC}"
|
||||||
|
read -p "Continue? (y/N) " -n 1 -r
|
||||||
|
echo
|
||||||
|
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||||
|
echo "Cleanup cancelled"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Perform cleanup
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}Cleaning up database...${NC}"
|
||||||
|
|
||||||
|
# Delete invalid lots
|
||||||
|
if [ "$INVALID_LOTS" -gt 0 ]; then
|
||||||
|
echo -e " ${BLUE}[1/2] Deleting invalid lots...${NC}"
|
||||||
|
sqlite3 "${LOCAL_DB_PATH}" "DELETE FROM lots WHERE auction_id IS NULL OR auction_id = '';"
|
||||||
|
echo -e " ${GREEN}✓ Deleted ${INVALID_LOTS} invalid lots${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Delete orphaned images
|
||||||
|
if [ "$ORPHANED_IMAGES" -gt 0 ]; then
|
||||||
|
echo -e " ${BLUE}[2/2] Deleting orphaned images...${NC}"
|
||||||
|
sqlite3 "${LOCAL_DB_PATH}" "DELETE FROM images WHERE lot_id NOT IN (SELECT lot_id FROM lots);"
|
||||||
|
echo -e " ${GREEN}✓ Deleted ${ORPHANED_IMAGES} orphaned images${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Vacuum database to reclaim space
|
||||||
|
echo -e " ${BLUE}[3/3] Compacting database...${NC}"
|
||||||
|
sqlite3 "${LOCAL_DB_PATH}" "VACUUM;"
|
||||||
|
echo -e " ${GREEN}✓ Database compacted${NC}"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}╔════════════════════════════════════════════════════════╗${NC}"
|
||||||
|
echo -e "${GREEN}║ Cleanup completed successfully ║${NC}"
|
||||||
|
echo -e "${GREEN}╚════════════════════════════════════════════════════════╝${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Show final state
|
||||||
|
echo -e "${BLUE}Final database state:${NC}"
|
||||||
|
sqlite3 "${LOCAL_DB_PATH}" <<EOF
|
||||||
|
.mode box
|
||||||
|
SELECT
|
||||||
|
'Total lots' as metric,
|
||||||
|
COUNT(*) as count
|
||||||
|
FROM lots
|
||||||
|
UNION ALL
|
||||||
|
SELECT
|
||||||
|
'Total images',
|
||||||
|
COUNT(*)
|
||||||
|
FROM images;
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
DB_SIZE=$(du -h "${LOCAL_DB_PATH}" | cut -f1)
|
||||||
|
echo -e "${BLUE}Database size: ${DB_SIZE}${NC}"
|
||||||
|
echo ""
|
||||||
@@ -69,11 +69,14 @@ sync_database() {
|
|||||||
ssh ${REMOTE_HOST} "docker run --rm -v ${REMOTE_VOLUME}:/data -v ${REMOTE_TMP}:${REMOTE_TMP} alpine cp /data/cache.db ${REMOTE_TMP}/cache.db"
|
ssh ${REMOTE_HOST} "docker run --rm -v ${REMOTE_VOLUME}:/data -v ${REMOTE_TMP}:${REMOTE_TMP} alpine cp /data/cache.db ${REMOTE_TMP}/cache.db"
|
||||||
|
|
||||||
echo -e "${YELLOW}[2/3] Downloading database from remote server...${NC}"
|
echo -e "${YELLOW}[2/3] Downloading database from remote server...${NC}"
|
||||||
# Create backup of existing local database
|
# Create backup and remove old local database
|
||||||
if [ -f "${LOCAL_DB_PATH}" ]; then
|
if [ -f "${LOCAL_DB_PATH}" ]; then
|
||||||
BACKUP_PATH="${LOCAL_DB_PATH}.backup-$(date +%Y%m%d-%H%M%S)"
|
BACKUP_PATH="${LOCAL_DB_PATH}.backup-$(date +%Y%m%d-%H%M%S)"
|
||||||
echo -e "${BLUE} Backing up existing database to: ${BACKUP_PATH}${NC}"
|
echo -e "${BLUE} Backing up existing database to: ${BACKUP_PATH}${NC}"
|
||||||
cp "${LOCAL_DB_PATH}" "${BACKUP_PATH}"
|
cp "${LOCAL_DB_PATH}" "${BACKUP_PATH}"
|
||||||
|
|
||||||
|
echo -e "${BLUE} Removing old local database...${NC}"
|
||||||
|
rm -f "${LOCAL_DB_PATH}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Download new database
|
# Download new database
|
||||||
@@ -98,6 +101,32 @@ UNION ALL
|
|||||||
SELECT 'images', COUNT(*) FROM images
|
SELECT 'images', COUNT(*) FROM images
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT 'cache', COUNT(*) FROM cache;
|
SELECT 'cache', COUNT(*) FROM cache;
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Show data quality report
|
||||||
|
echo -e "${BLUE} Data quality:${NC}"
|
||||||
|
sqlite3 "${LOCAL_DB_PATH}" <<EOF
|
||||||
|
.mode box
|
||||||
|
SELECT
|
||||||
|
'Valid lots' as metric,
|
||||||
|
COUNT(*) as count,
|
||||||
|
ROUND(COUNT(*) * 100.0 / (SELECT COUNT(*) FROM lots), 2) || '%' as percentage
|
||||||
|
FROM lots
|
||||||
|
WHERE auction_id IS NOT NULL AND auction_id != ''
|
||||||
|
UNION ALL
|
||||||
|
SELECT
|
||||||
|
'Invalid lots (missing auction_id)',
|
||||||
|
COUNT(*),
|
||||||
|
ROUND(COUNT(*) * 100.0 / (SELECT COUNT(*) FROM lots), 2) || '%'
|
||||||
|
FROM lots
|
||||||
|
WHERE auction_id IS NULL OR auction_id = ''
|
||||||
|
UNION ALL
|
||||||
|
SELECT
|
||||||
|
'Lots with intelligence fields',
|
||||||
|
COUNT(*),
|
||||||
|
ROUND(COUNT(*) * 100.0 / (SELECT COUNT(*) FROM lots), 2) || '%'
|
||||||
|
FROM lots
|
||||||
|
WHERE followers_count IS NOT NULL OR estimated_min IS NOT NULL;
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,20 @@ class ImageProcessingService {
|
|||||||
*/
|
*/
|
||||||
boolean processImage(int imageId, String localPath, long lotId) {
|
boolean processImage(int imageId, String localPath, long lotId) {
|
||||||
try {
|
try {
|
||||||
|
// Check if file exists before processing
|
||||||
|
var file = new java.io.File(localPath);
|
||||||
|
if (!file.exists() || !file.canRead()) {
|
||||||
|
log.warn(" Image file not accessible: {}", localPath);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check file size (skip very large files that might cause issues)
|
||||||
|
long fileSizeBytes = file.length();
|
||||||
|
if (fileSizeBytes > 50 * 1024 * 1024) { // 50 MB limit
|
||||||
|
log.warn(" Image file too large ({}MB): {}", fileSizeBytes / (1024 * 1024), localPath);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// Run object detection on the local file
|
// Run object detection on the local file
|
||||||
var labels = detector.detectObjects(localPath);
|
var labels = detector.detectObjects(localPath);
|
||||||
|
|
||||||
|
|||||||
@@ -104,7 +104,16 @@ public class ObjectDetectionService {
|
|||||||
for (var i = 0; i < out.rows(); i++) {
|
for (var i = 0; i < out.rows(); i++) {
|
||||||
var data = out.get(i, 0);
|
var data = out.get(i, 0);
|
||||||
if (data == null) continue;
|
if (data == null) continue;
|
||||||
|
|
||||||
// The first 5 numbers are bounding box, then class scores
|
// The first 5 numbers are bounding box, then class scores
|
||||||
|
// Check if data has enough elements before copying
|
||||||
|
int expectedLength = 5 + classNames.size();
|
||||||
|
if (data.length < expectedLength) {
|
||||||
|
log.warn("Detection data too short: expected {} elements, got {}. Skipping detection.",
|
||||||
|
expectedLength, data.length);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
var scores = new double[classNames.size()];
|
var scores = new double[classNames.size()];
|
||||||
System.arraycopy(data, 5, scores, 0, scores.length);
|
System.arraycopy(data, 5, scores, 0, scores.length);
|
||||||
var classId = argMax(scores);
|
var classId = argMax(scores);
|
||||||
@@ -117,6 +126,13 @@ public class ObjectDetectionService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Release resources
|
||||||
|
image.release();
|
||||||
|
blob.release();
|
||||||
|
for (var out : outs) {
|
||||||
|
out.release();
|
||||||
|
}
|
||||||
return labels;
|
return labels;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -94,10 +94,20 @@ public class QuarkusWorkflowScheduler {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
LOG.infof(" → Processing %d images", pendingImages.size());
|
// Limit batch size to prevent thread blocking (max 100 images per run)
|
||||||
|
final int MAX_BATCH_SIZE = 100;
|
||||||
|
int totalPending = pendingImages.size();
|
||||||
|
if (totalPending > MAX_BATCH_SIZE) {
|
||||||
|
LOG.infof(" → Found %d pending images, processing first %d (batch limit)",
|
||||||
|
totalPending, MAX_BATCH_SIZE);
|
||||||
|
pendingImages = pendingImages.subList(0, MAX_BATCH_SIZE);
|
||||||
|
} else {
|
||||||
|
LOG.infof(" → Processing %d images", totalPending);
|
||||||
|
}
|
||||||
|
|
||||||
var processed = 0;
|
var processed = 0;
|
||||||
var detected = 0;
|
var detected = 0;
|
||||||
|
var failed = 0;
|
||||||
|
|
||||||
for (var image : pendingImages) {
|
for (var image : pendingImages) {
|
||||||
try {
|
try {
|
||||||
@@ -121,19 +131,26 @@ public class QuarkusWorkflowScheduler {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
failed++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rate limiting (lighter since no network I/O)
|
// Rate limiting (lighter since no network I/O)
|
||||||
Thread.sleep(100);
|
Thread.sleep(100);
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
failed++;
|
||||||
LOG.warnf(" ⚠️ Failed to process image: %s", e.getMessage());
|
LOG.warnf(" ⚠️ Failed to process image: %s", e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var duration = System.currentTimeMillis() - start;
|
var duration = System.currentTimeMillis() - start;
|
||||||
LOG.infof(" ✓ Processed %d images, detected objects in %d (%.1fs)",
|
LOG.infof(" ✓ Processed %d/%d images, detected objects in %d, failed %d (%.1fs)",
|
||||||
processed, detected, duration / 1000.0);
|
processed, totalPending, detected, failed, duration / 1000.0);
|
||||||
|
|
||||||
|
if (totalPending > MAX_BATCH_SIZE) {
|
||||||
|
LOG.infof(" → %d images remaining for next run", totalPending - MAX_BATCH_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
LOG.errorf(e, " ❌ Image processing failed: %s", e.getMessage());
|
LOG.errorf(e, " ❌ Image processing failed: %s", e.getMessage());
|
||||||
|
|||||||
7
src/main/resources/META-INF/resources/favicon.svg
Normal file
7
src/main/resources/META-INF/resources/favicon.svg
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||||
|
<rect width="100" height="100" fill="#2563eb" rx="15"/>
|
||||||
|
<path d="M25 40 L50 20 L75 40 L75 70 L25 70 Z" fill="#ffffff" stroke="#ffffff" stroke-width="2"/>
|
||||||
|
<circle cx="50" cy="45" r="8" fill="#2563eb"/>
|
||||||
|
<rect x="40" y="55" width="20" height="3" fill="#2563eb"/>
|
||||||
|
<text x="50" y="90" font-family="Arial" font-size="12" fill="#ffffff" text-anchor="middle" font-weight="bold">AUCTION</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 465 B |
@@ -1355,11 +1355,32 @@ function updatePagination(total, perPage, currentPage) {
|
|||||||
|
|
||||||
// Trigger workflow with progress tracking
|
// Trigger workflow with progress tracking
|
||||||
async function triggerWorkflow(workflow) {
|
async function triggerWorkflow(workflow) {
|
||||||
const statusId = `status-${workflow.split('-')[0]}`;
|
// Map workflow names to DOM element IDs
|
||||||
const progressId = `progress-${workflow.split('-')[0]}`;
|
const idMap = {
|
||||||
|
'scraper-import': 'scraper',
|
||||||
|
'image-processing': 'images',
|
||||||
|
'bid-monitoring': 'bids',
|
||||||
|
'closing-alerts': 'alerts'
|
||||||
|
};
|
||||||
|
|
||||||
|
const elementId = idMap[workflow];
|
||||||
|
if (!elementId) {
|
||||||
|
console.error('Unknown workflow:', workflow);
|
||||||
|
showToast(`Unknown workflow: ${workflow}`, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusId = `status-${elementId}`;
|
||||||
|
const progressId = `progress-${elementId}`;
|
||||||
const statusEl = document.getElementById(statusId);
|
const statusEl = document.getElementById(statusId);
|
||||||
const progressEl = document.getElementById(progressId);
|
const progressEl = document.getElementById(progressId);
|
||||||
|
|
||||||
|
if (!statusEl || !progressEl) {
|
||||||
|
console.error('Workflow UI elements not found:', statusId, progressId);
|
||||||
|
showToast(`Cannot trigger workflow: UI elements not found`, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Update UI
|
// Update UI
|
||||||
statusEl.innerHTML = '<span class="inline-block w-2 h-2 bg-blue-500 rounded-full mr-1 animate-pulse"></span>Running';
|
statusEl.innerHTML = '<span class="inline-block w-2 h-2 bg-blue-500 rounded-full mr-1 animate-pulse"></span>Running';
|
||||||
statusEl.className = 'text-xs font-semibold px-2 py-1 rounded-full bg-blue-100 text-blue-800';
|
statusEl.className = 'text-xs font-semibold px-2 py-1 rounded-full bg-blue-100 text-blue-800';
|
||||||
|
|||||||
Reference in New Issue
Block a user