Compare commits

...

19 Commits

Author SHA1 Message Date
Alby96 e18a09e1da Gestione massiva limiti prodotto e ottimizzazione ticker
Aggiunta barra azioni per gestione massiva limiti prodotto in Statistics.razor (applica, salva, attiva/disattiva, copia consigliati). Uniformati simboli euro e messaggi in italiano. Ottimizzata la logica del ticker: controllo puntata ora avviene prima del polling, gestione fine asta differita tramite PendingEndState. Introdotto controllo esplicito su MaxClicks per asta. Implementata cache delle impostazioni in SettingsManager per ridurre accessi disco. Vari fix minori e miglioramenti di robustezza.
2026-03-03 08:53:38 +01:00
Alby96 f3262a0497 Log aste strutturato, limiti prodotto e UI statistiche
- Log per-asta ora strutturato con livelli, categorie e deduplicazione; motivi di blocco puntata tracciati in modo dettagliato e throttled
- Nuova visualizzazione log compatta e colorata nella UI
- Migliorate statistiche prodotto: aggiunta mediana prezzo, flag UseCustomLimits e editing limiti inline
- Impostazione priorità limiti nuove aste (globali vs personalizzati)
- Refactoring: rimossi limiti reset, UI statistiche rinnovata, ordinamenti e filtri avanzati
- Aggiornato schema DB (MedianFinalPrice, UseCustomLimits)
- Diagnostica periodica e log dettagliato su ticker/controlli
2026-02-16 23:10:04 +01:00
Alby96 690f7e636a Ottimizzazione RAM, UI e sistema di timing aste
- Ridotto consumo RAM: limiti log, pulizia e compattazione dati aste, timer periodico di cleanup
- UI più fluida: cache locale aste, throttling aggiornamenti, refresh log solo se necessario
- Nuovo sistema Ticker Loop: timing configurabile, strategie solo vicino alla scadenza, feedback puntate tardive
- Migliorato layout e splitter, log visivo, gestione cache HTML
- Aggiornata UI impostazioni e fix vari per performance e thread-safety
2026-02-07 19:28:30 +01:00
Alby96 5b95f18889 Restyling monitor aste: toolbar compatta, split panel, UX
- Nuova toolbar compatta con azioni rapide e indicatori stato aste
- Layout a pannelli ridimensionabili con splitter drag&drop
- Tabella aste compatta, ping colorato, azioni XS
- Pulsanti per rimozione aste per stato (attive, vinte, ecc.)
- Dettagli asta sempre visibili in pannello inferiore
- Statistiche prodotti: filtro, ordinamento, editing limiti default
- Limiti default prodotto salvati in DB, applicabili a tutte le aste
- Migliorata sidebar utente con info sessione sempre visibili
- Log motivi blocco puntata sempre visibili, suggerimenti timing
- Miglioramenti filtri, UX responsive, fix minori e feedback visivi
2026-02-06 15:35:53 +01:00
Alby96 45dd205270 Miglioramento commenti e semplificazione logica puntata
Correzione della codifica dei caratteri speciali nei commenti e nei log, aggiunta dei namespace mancanti, semplificazione della condizione per la puntata automatica e aggiornamento dei simboli di valuta. Refactoring generale dei commenti per maggiore chiarezza e manutenzione, senza modifiche alla logica principale.
2026-02-05 09:36:40 +01:00
Alby96 0764b0b625 Semplifica timing puntata, logging e controllo convenienza
- Timing di puntata ora gestito solo da offset fisso configurabile, rimosse strategie di compensazione latenza/jitter/offset dinamico
- Aggiunto controllo convenienza: blocca puntate se il costo supera il "Compra Subito" oltre una soglia configurabile
- Logging granulare: nuove opzioni per log selettivo (puntate, strategie, valore, competizione, timing, errori, stato, profiling avversari)
- Persistenza stato browser aste (categoria, ricerca) tramite ApplicationStateService
- Fix conteggio puntate per bidder, rimosso rilevamento "Last Second Sniper", aggiunta strategia "Price Momentum"
- Refactoring e pulizia: rimozione codice obsoleto, migliorata documentazione e thread-safety
2026-02-05 09:28:58 +01:00
Alby96 8befcb8abf Rework UI, log e strategie; fix selezione aste
- Interfaccia impostazioni più compatta e responsive, rimosse animazioni popup su hover, evidenziazione con colore
- Ottimizzazione visualizzazione puntate e statistiche, evidenza puntate proprie
- Rework sistema di log: eliminazione duplicati e info inutili, maggiore leggibilità
- Aggiunti nuovi stati e motivazioni per cui il bot non punta (fuori range, strategia, ecc)
- Fix critico: selezione aste ora sempre aggiornata e salvata correttamente
- Migliorata logica aggiunta puntate mancanti, niente duplicati
- Rimossa logica errata "Entry Point": limiti utente ora rigidi, usato solo per suggerimenti
- Aggiornata documentazione e guide per riflettere le nuove funzionalità
2026-02-03 10:50:51 +01:00
Alby96 89aed8a458 Migliorie UI, log aste, strategie e statistiche puntatori
- Ordinamento colonne griglia aste e indicatori visivi
- Nuovo pulsante per rimozione rapida aste terminate
- Log aste con deduplicazione e contatore
- Statistiche puntatori cumulative e più affidabili
- Cronologia puntate senza duplicati consecutivi
- Strategie di puntata semplificate: entry point, anti-bot, user exhaustion
- UI più compatta, hover moderni, evidenziazione puntate utente
- Correzioni internazionalizzazione e pulizia codice
2026-02-03 00:00:33 +01:00
Alby96 ae861e78d2 Implementate strategie avanzate e tracking aste v1.3.0
- Aggiunto BidStrategyService: adaptive latency, jitter, offset dinamico, heat metric, soft retreat, probabilistic bidding, profiling avversari, bankroll manager.
- Esteso AuctionInfo con metriche avanzate: latenze, collisioni, heat, duello, tracking sessione, override strategie.
- Nuova sezione "Strategie Avanzate" in Settings (UI) con opzioni dettagliate e bulk update.
- Miglioramenti UX: auto-scroll log, filtri e dettagli avanzati in Statistics, gestione nomi prodotti, pulsanti sempre attivi.
- Fix bug Blazor (layout, redirect, log, conteggio puntate, entità HTML).
- Aggiornata documentazione, changelog, guide Docker/Gitea.
- Versione incrementata a 1.3.0. Migrazione database per nuove metriche e tracking completo.
2026-01-28 11:37:40 +01:00
Alby96 77eb9943d0 Gestione avanzata database e rimozione MaxClicks
Aggiunta sezione impostazioni per manutenzione database (auto-salvataggio, pulizia duplicati/incompleti, retention, ottimizzazione). Implementati metodi asincroni in DatabaseService per pulizia e statistiche. Pulizia automatica all’avvio secondo impostazioni. Rimossa la proprietà MaxClicks da modello, UI e logica. Migliorata la sicurezza thread-safe e la trasparenza nella gestione dati. Spostato il badge versione nelle info applicazione.
2026-01-24 01:30:49 +01:00
Alby96 a0ec72f6c0 Refactor: solo SQLite, limiti auto, UI statistiche nuova
Rimosso completamente il supporto a PostgreSQL: ora tutte le statistiche e i dati persistenti usano solo SQLite, con percorso configurabile tramite DATA_PATH per Docker/volumi. Aggiunta gestione avanzata delle statistiche per prodotto, limiti consigliati calcolati automaticamente e applicabili dalla UI. Rinnovata la pagina Statistiche con tabelle aste recenti e prodotti, rimosso il supporto a grafici legacy e a "Puntate Gratuite". Migliorata la ricerca e la gestione delle aste nel browser, aggiunta diagnostica avanzata e logging dettagliato per il database. Aggiornati Dockerfile e docker-compose: l'app è ora self-contained e pronta per l'uso senza database esterni.
2026-01-23 16:56:03 +01:00
Alby96 21a1d57cab Migliora badge stato aste: nuovi colori, icone, animazioni
Rivisti i metodi di calcolo e visualizzazione dello stato delle aste in Index.razor.cs, distinguendo tra stati di sistema e controllati dall’utente. Aggiunte nuove classi CSS e animazioni in modern-pages.css per badge più chiari, compatti e animati. Mantenuta compatibilità con classi Bootstrap legacy. Migliorata la leggibilità e l’usabilità della tabella aste.
2026-01-22 15:28:05 +01:00
Alby96 2833cd0487 Aggiornamento live aste, azioni rapide e scroll infinito
- Aggiornamento automatico degli stati delle aste ogni 500ms, rimosso il bottone manuale "Aggiorna Prezzi"
- Aggiunti pulsanti per copiare il link e aprire l'asta in nuova scheda
- Possibilità di rimuovere aste dal monitor direttamente dalla lista
- Caricamento aste ottimizzato: scroll infinito senza duplicati tramite nuova API get_auction_updates.php
- Migliorato il parsing dei dati e la precisione del countdown usando il timestamp del server
- Refactoring vari per migliorare la reattività e l'esperienza utente
2026-01-22 11:43:59 +01:00
Alby96 865bfa2752 Aggiunta pagina "Esplora Aste" per browser pubblico
Introdotta la funzionalità di esplorazione delle aste pubbliche di Bidoo senza login, accessibile dal menu principale.
Aggiunti nuovi modelli (`BidooBrowserAuction`, `BidooCategoryInfo`) e servizio (`BidooBrowserService`) per scraping e polling delle aste e categorie.
Creata la pagina Blazor `AuctionBrowser.razor` con griglia responsive, badge, filtri per categoria, caricamento incrementale e aggiornamento automatico degli stati.
Aggiornati i servizi in `Program.cs` e aggiunti nuovi stili CSS per la UI moderna.
Le aste possono essere aggiunte rapidamente al monitor personale. Parsing robusto e fallback su categorie predefinite in caso di errori.
2026-01-22 00:08:16 +01:00
Alby96 70ed8f0a61 Modernizzazione UI: nuovo tema dark e sidebar rivista
Aggiorna l’interfaccia Blazor con una palette dark moderna, font Inter, e una sidebar ridisegnata.
Riorganizza layout e navigazione, migliora la gestione errori e introduce nuovi stili per card, bottoni, input e badge.
Aggiunto `modern-pages.css` per header, griglie statistiche, alert e form più coerenti e attuali.
Migliora leggibilità, navigazione e user experience complessiva.
2026-01-21 17:39:15 +01:00
Alby96 ed42a41bcd Autenticazione Identity: login sicuro, lockout, UI aggiornata
- Integra ASP.NET Core Identity: login/password, lockout brute-force, cookie sicuri, password policy forte
- Seed automatico utente admin da variabili ambiente (fallback password temporanea forte)
- Tutte le pagine principali ora protette con [Authorize] e redirect automatico a /login
- Nuovo layout login/logout pulito senza sidebar, spinner durante redirect
- NavMenu mostra utente autenticato e logout
- Rimosse credenziali Bidoo da env/Docker: ora solo cookie sessione da UI
- Aggiornata documentazione: sicurezza, deploy, backup, troubleshooting
- Fix NavigationException, SectionRegistry, errori header read-only
- Versione incrementata a 1.2.0, pronto per deploy production Tailscale/Unraid
2026-01-21 17:00:51 +01:00
Alby96 6a3f931431 Fix definitivo porta 8080 + healthcheck e doc v1.1.2
Forzato UseUrls() su 8080 per evitare override e garantire che il container ascolti sempre sulla porta corretta. Migliorati i parametri del healthcheck Docker per Blazor Server (timeout 30s, start-period 90s, retries 5). Aggiornati metadati di versione a 1.1.2. Aggiunta documentazione dettagliata sul fix e corretti caratteri accentati nel changelog.
2026-01-21 12:42:34 +01:00
Alby96 ef1bc92e67 fix: container ascolta su porta 8080 (non 5000)
Rimosso override esplicito porta HTTP in Program.cs: ora la porta è gestita solo da ASPNETCORE_URLS (default 8080), risolvendo il bug che impediva l’accesso web al container. Aggiornati Dockerfile, docker-compose.yml e documentazione per riflettere la nuova configurazione. Versione incrementata a 1.1.1 (PATCH). HTTPS resta disabilitato di default; configurazione centralizzata e override semplice via env. Aggiunti changelog, troubleshooting e script bump-version aggiornato.
2026-01-20 23:06:01 +01:00
Alby96 343f171d6a Semplifica workflow Docker/Gitea, versionamento automatico
- Corretto path registry Docker: ora 3 livelli (owner/image)
- Aggiunto target post-build: tagging e push automatico su Gitea (latest + versione)
- Inclusi Dockerfile, .dockerignore e profilo pubblicazione nel progetto
- Kestrel: HTTPS configurabile, gestione certificati e log migliorati
- HSTS/HTTPS redirection ora condizionali
- Aggiornate guide e checklist: workflow, troubleshooting, best practice
- Unificato profilo di pubblicazione: versionamento da <Version> in .csproj
- Processo di build/push ora integrato, automatico e conforme a Gitea
2026-01-20 21:57:48 +01:00
55 changed files with 409883 additions and 3284 deletions
+20 -30
View File
@@ -3,11 +3,21 @@
# === ASP.NET Core Configuration === # === ASP.NET Core Configuration ===
ASPNETCORE_ENVIRONMENT=Production ASPNETCORE_ENVIRONMENT=Production
ASPNETCORE_URLS=http://+:5000;https://+:5001 ASPNETCORE_URLS=http://+:8080
# === HTTPS Certificate === # === AUTENTICAZIONE APPLICAZIONE (SICUREZZA) ===
# Password per il certificato PFX # Username amministratore
CERT_PASSWORD=AutoBidder2024 ADMIN_USERNAME=admin
# Password amministratore (OBBLIGATORIO in produzione!)
# REQUISITI: min 12 caratteri, maiuscole, minuscole, numeri, simboli
# Esempio: Admin@SecurePass2024!
ADMIN_PASSWORD=
# === NOTA: SESSIONE BIDOO ===
# Non servono credenziali Bidoo!
# Il cookie di sessione Bidoo viene configurato manualmente
# dall'interfaccia web in Settings ? Sessione Bidoo
# === PostgreSQL Database (Statistiche) === # === PostgreSQL Database (Statistiche) ===
# Username PostgreSQL # Username PostgreSQL
@@ -20,34 +30,14 @@ POSTGRES_PASSWORD=autobidder_password
POSTGRES_DB=autobidder_stats POSTGRES_DB=autobidder_stats
# Usa PostgreSQL per statistiche (true/false) # Usa PostgreSQL per statistiche (true/false)
DATABASE_USE_POSTGRES=true USE_POSTGRES=true
# Auto-crea schema PostgreSQL se mancante (true/false) # === Application Settings ===
DATABASE_AUTO_CREATE_SCHEMA=true # Logging level (Debug, Information, Warning, Error)
LOG_LEVEL=Information
# Fallback a SQLite se PostgreSQL non disponibile (true/false) # Porta applicazione (default: 8080 container, mappata su host)
DATABASE_FALLBACK_TO_SQLITE=true APP_PORT=5000
# === Gitea Container Registry ===
# URL del registry (senza https://)
GITEA_REGISTRY=192.168.30.23/Alby96
# Username Gitea
GITEA_USERNAME=Alby96
# Access Token Gitea (genera su: https://192.168.30.23/user/settings/applications)
# Scope richiesti: write:package, read:package
GITEA_PASSWORD=ghp_your_token_here
# === Deployment Configuration ===
# IP o hostname del server di deploy
DEPLOY_HOST=192.168.30.23
# User SSH per deploy
DEPLOY_USER=deploy
# Path alla chiave privata SSH (per CI/CD)
# DEPLOY_SSH_KEY_PATH=/path/to/ssh/key
# === Database Configuration === # === Database Configuration ===
# Path database SQLite locale (default: /app/data/autobidder.db in container) # Path database SQLite locale (default: /app/data/autobidder.db in container)
+18 -5
View File
@@ -1,6 +1,18 @@
<Router AppAssembly="@typeof(App).Assembly"> <CascadingAuthenticationState>
<Router AppAssembly="@typeof(App).Assembly">
<Found Context="routeData"> <Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" /> <AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">
<NotAuthorized>
@if (context.User.Identity?.IsAuthenticated != true)
{
<RedirectToLogin />
}
else
{
<p>Non sei autorizzato ad accedere a questa risorsa.</p>
}
</NotAuthorized>
</AuthorizeRouteView>
<FocusOnNavigate RouteData="@routeData" Selector="h1" /> <FocusOnNavigate RouteData="@routeData" Selector="h1" />
</Found> </Found>
<NotFound> <NotFound>
@@ -13,11 +25,12 @@
<line x1="12" y1="16" x2="12.01" y2="16"></line> <line x1="12" y1="16" x2="12.01" y2="16"></line>
</svg> </svg>
<h1 style="font-size: 1.5rem; margin-bottom: 0.5rem;">Pagina non trovata</h1> <h1 style="font-size: 1.5rem; margin-bottom: 0.5rem;">Pagina non trovata</h1>
<p style="color: var(--text-muted);">Spiacenti, non c'e' nulla a questo indirizzo.</p> <p style="color: var(--text-muted);">Spiacenti, non c'è nulla a questo indirizzo.</p>
<a href="/" style="color: var(--primary-color); text-decoration: none; margin-top: 1rem; display: inline-block;"> <a href="/" style="color: var(--primary-color); text-decoration: none; margin-top: 1rem; display: inline-block;">
? Torna alla Home ?? Torna alla Home
</a> </a>
</div> </div>
</LayoutView> </LayoutView>
</NotFound> </NotFound>
</Router> </Router>
</CascadingAuthenticationState>
+82 -6
View File
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
@@ -7,17 +7,21 @@
<AssemblyName>AutoBidder</AssemblyName> <AssemblyName>AutoBidder</AssemblyName>
<RootNamespace>AutoBidder</RootNamespace> <RootNamespace>AutoBidder</RootNamespace>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS> <DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<DockerfileContext>.</DockerfileContext>
<DockerfileFile>Dockerfile</DockerfileFile>
<!-- Versioning per Docker & Gitea Registry --> <!-- Versioning per Docker & Gitea Registry -->
<Version>1.0.0</Version> <!-- v1.3.0: Database management + bug fixes (duplicates, race conditions, warnings) -->
<AssemblyVersion>1.0.0.0</AssemblyVersion> <Version>1.3.0</Version>
<FileVersion>1.0.0.0</FileVersion> <AssemblyVersion>1.3.0.0</AssemblyVersion>
<InformationalVersion>1.0.0</InformationalVersion> <FileVersion>1.3.0.0</FileVersion>
<InformationalVersion>1.3.0</InformationalVersion>
<!-- Metadata immagine Docker --> <!-- Metadata immagine Docker -->
<ContainerImageName>autobidder</ContainerImageName> <ContainerImageName>autobidder</ContainerImageName>
<ContainerImageTag>$(Version)</ContainerImageTag> <ContainerImageTag>$(Version)</ContainerImageTag>
<ContainerRegistry>gitea.encke-hake.ts.net/alby96/mimante</ContainerRegistry> <!-- CORRETTO: Convenzione Gitea {registro}/{proprietario}/{immagine} -->
<ContainerRegistry>gitea.encke-hake.ts.net/alby96</ContainerRegistry>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
@@ -63,6 +67,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.0" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.0" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -76,6 +81,77 @@
<None Include=".gitea\workflows\deploy.yml" /> <None Include=".gitea\workflows\deploy.yml" />
<None Include=".gitea\workflows\health-check.yml" /> <None Include=".gitea\workflows\health-check.yml" />
<None Include=".github\workflows\ci-cd.yml" /> <None Include=".github\workflows\ci-cd.yml" />
<None Include="Dockerfile" />
<None Include=".dockerignore" />
<None Include="Properties\PublishProfiles\GiteaRegistry-Versioned.pubxml.user" />
</ItemGroup> </ItemGroup>
<!-- ============================================ -->
<!-- POST-BUILD TARGET: Push automatico su Gitea -->
<!-- con versionamento da <Version> della solution -->
<!-- ============================================ -->
<Target Name="PushDockerImageToGitea" AfterTargets="Publish" Condition="'$(PushToGiteaRegistry)' == 'true'">
<PropertyGroup>
<GiteaRegistry>gitea.encke-hake.ts.net/alby96</GiteaRegistry>
<LocalImageName>autobidder</LocalImageName>
<GiteaImageLatest>$(GiteaRegistry)/$(LocalImageName):latest</GiteaImageLatest>
<GiteaImageVersion>$(GiteaRegistry)/$(LocalImageName):$(Version)</GiteaImageVersion>
</PropertyGroup>
<Message Importance="high" Text="" />
<Message Importance="high" Text="+-------------------------------------------------------------------+" />
<Message Importance="high" Text="¦ POST-BUILD: Pubblicazione su Gitea Container Registry ¦" />
<Message Importance="high" Text="+-------------------------------------------------------------------+" />
<Message Importance="high" Text="" />
<Message Importance="high" Text="?? Solution Version: $(Version)" />
<Message Importance="high" Text="?? Local Image: $(LocalImageName):latest" />
<Message Importance="high" Text="??? Target Tags:" />
<Message Importance="high" Text=" • $(GiteaImageLatest)" />
<Message Importance="high" Text=" • $(GiteaImageVersion)" />
<Message Importance="high" Text="" />
<Message Importance="high" Text="-------------------------------------------------------------------" />
<Message Importance="high" Text="??? Tagging images..." />
<Message Importance="high" Text="-------------------------------------------------------------------" />
<!-- Tag immagine locale per Gitea (latest) -->
<Exec Command="docker tag $(LocalImageName):latest $(GiteaImageLatest)" />
<Message Importance="high" Text="? Tagged: $(GiteaImageLatest)" />
<!-- Tag immagine locale per Gitea (versione solution) -->
<Exec Command="docker tag $(LocalImageName):latest $(GiteaImageVersion)" />
<Message Importance="high" Text="? Tagged: $(GiteaImageVersion)" />
<Message Importance="high" Text="" />
<Message Importance="high" Text="-------------------------------------------------------------------" />
<Message Importance="high" Text="?? Pushing to Gitea Registry..." />
<Message Importance="high" Text="-------------------------------------------------------------------" />
<!-- Push latest -->
<Exec Command="docker push $(GiteaImageLatest)" />
<Message Importance="high" Text="? Pushed: $(GiteaImageLatest)" />
<!-- Push version -->
<Exec Command="docker push $(GiteaImageVersion)" />
<Message Importance="high" Text="? Pushed: $(GiteaImageVersion)" />
<Message Importance="high" Text="" />
<Message Importance="high" Text="+-------------------------------------------------------------------+" />
<Message Importance="high" Text="¦ ? PUBBLICAZIONE COMPLETATA CON SUCCESSO! ¦" />
<Message Importance="high" Text="+-------------------------------------------------------------------+" />
<Message Importance="high" Text="" />
<Message Importance="high" Text="?? Visualizza su Gitea:" />
<Message Importance="high" Text=" https://gitea.encke-hake.ts.net/Alby96/-/packages/container/autobidder" />
<Message Importance="high" Text="" />
<Message Importance="high" Text="?? Tag pubblicati:" />
<Message Importance="high" Text=" • latest (sempre aggiornato all'ultima versione)" />
<Message Importance="high" Text=" • $(Version) (versione solution corrente)" />
<Message Importance="high" Text="" />
<Message Importance="high" Text="?? Pull command:" />
<Message Importance="high" Text=" docker pull $(GiteaImageLatest)" />
<Message Importance="high" Text=" docker pull $(GiteaImageVersion)" />
<Message Importance="high" Text="" />
<Message Importance="high" Text="-------------------------------------------------------------------" />
<Message Importance="high" Text="" />
</Target>
</Project> </Project>
-104
View File
@@ -1,104 +0,0 @@
# ?? AutoBidder - Docker Deploy su Gitea
Setup minimalista per build e deploy Docker.
---
## ?? Requisiti
- Docker Desktop running
- Login Gitea Registry:
```powershell
docker login gitea.encke-hake.ts.net
# Username: alby96
# Password: <personal-access-token>
```
**Genera token**: https://gitea.encke-hake.ts.net/user/settings/applications ? Permissions: `write:packages`
---
## ?? Publish da Visual Studio
```
Build ? Publish ? Docker ? Publish
```
**Automatico**:
- Build immagine Docker
- Tag: `latest`, `1.0.0`, `1.0.0-20260118`
- Push su Gitea Registry
**Registry**: https://gitea.encke-hake.ts.net/alby96/mimante/-/packages/container/autobidder
---
## ?? Aggiornare Versione
Modifica `AutoBidder.csproj`:
```xml
<PropertyGroup>
<Version>1.0.1</Version>
</PropertyGroup>
```
Poi publish come sopra.
---
## ?? Deploy Unraid
### Via Template
1. Unraid ? Docker ? Add Template
2. URL: `https://192.168.30.23/Alby96/Mimante/raw/branch/docker/deployment/unraid-template.xml`
3. Install "AutoBidder"
4. Configura:
- Port: `8888:8080`
- AppData: `/mnt/user/appdata/autobidder`
- PostgreSQL: `Host=192.168.30.23;Port=5432;...`
5. Apply
### Via Docker Compose
```bash
docker-compose up -d
```
Accesso: http://localhost:8080
---
## ?? Troubleshooting
### Publish fallisce: "unauthorized"
```powershell
docker login gitea.encke-hake.ts.net
# Retry publish
```
### Container non parte
```powershell
# Verifica porta libera
netstat -ano | findstr :8080
# Rebuild
docker build -t test .
```
---
## ?? File Configurazione
| File | Scopo |
|------|-------|
| `Dockerfile` | Build immagine multi-stage |
| `docker-compose.yml` | Deploy con PostgreSQL |
| `Properties/PublishProfiles/Docker.pubxml` | Profilo publish Visual Studio |
| `deployment/unraid-template.xml` | Template Unraid |
---
**Setup completo! Build ? Publish ? Docker per deployare! ??**
+27
View File
@@ -0,0 +1,27 @@
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using AutoBidder.Models;
namespace AutoBidder.Data;
/// <summary>
/// DbContext per autenticazione Identity
/// </summary>
public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
{
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
}
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
// Personalizza nomi tabelle Identity (opzionale)
builder.Entity<ApplicationUser>(entity =>
{
entity.ToTable("Users");
});
}
}
+13 -3
View File
@@ -54,17 +54,27 @@ EXPOSE 8080
# Environment variables (overridable via docker-compose/unraid) # Environment variables (overridable via docker-compose/unraid)
ENV ASPNETCORE_URLS=http://+:8080 ENV ASPNETCORE_URLS=http://+:8080
ENV ASPNETCORE_ENVIRONMENT=Production ENV ASPNETCORE_ENVIRONMENT=Production
ENV Kestrel__EnableHttps=false
# Database path - tutti i database SQLite e dati persistenti
# Può essere sovrascritto nel docker-compose per mappare un volume persistente
ENV DATA_PATH=/app/Data
# Autenticazione applicazione (OBBLIGATORIO)
ENV ADMIN_USERNAME=admin
ENV ADMIN_PASSWORD=
# Health check # Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ # Aumentato timeout e start-period per Blazor Server
HEALTHCHECK --interval=30s --timeout=30s --start-period=90s --retries=5 \
CMD curl -f http://localhost:8080/ || exit 1 CMD curl -f http://localhost:8080/ || exit 1
# Labels for metadata # Labels for metadata
LABEL org.opencontainers.image.title="AutoBidder" \ LABEL org.opencontainers.image.title="AutoBidder" \
org.opencontainers.image.description="Sistema automatizzato gestione aste Bidoo - Blazor .NET 8" \ org.opencontainers.image.description="Sistema automatizzato gestione aste Bidoo - Blazor .NET 8" \
org.opencontainers.image.version="1.0.0" \ org.opencontainers.image.version="1.2.0" \
org.opencontainers.image.vendor="Alby96" \ org.opencontainers.image.vendor="Alby96" \
org.opencontainers.image.source="https://192.168.30.23/Alby96/Mimante" org.opencontainers.image.source="https://gitea.encke-hake.ts.net/Alby96/Mimante"
# Entry point # Entry point
ENTRYPOINT ["dotnet", "AutoBidder.dll"] ENTRYPOINT ["dotnet", "AutoBidder.dll"]
@@ -1,76 +0,0 @@
# Sezione Configurazione Database - Impostazioni
## ?? Nota Implementazione
La configurazione del database PostgreSQL è già completamente funzionante tramite:
1. **appsettings.json** - Connection strings e configurazione
2. **AppSettings** (Utilities/SettingsManager.cs) - Proprietà salvate:
- `UsePostgreSQL`
- `PostgresConnectionString`
- `AutoCreateDatabaseSchema`
- `FallbackToSQLite`
3. **Program.cs** - Inizializzazione automatica database
## ?? UI Settings (Opzionale)
Se si desidera aggiungere una sezione nella pagina `Settings.razor` per configurare PostgreSQL tramite UI,
le proprietà sono già disponibili nel modello `AppSettings`.
### Esempio Codice UI
```razor
<!-- CONFIGURAZIONE DATABASE -->
<div class="card mb-4">
<div class="card-header bg-secondary text-white">
<h5><i class="bi bi-database-fill"></i> Configurazione Database</h5>
</div>
<div class="card-body">
<div class="form-check form-switch mb-3">
<input type="checkbox" class="form-check-input" id="usePostgres" @bind="settings.UsePostgreSQL" />
<label class="form-check-label" for="usePostgres">
Usa PostgreSQL per Statistiche Avanzate
</label>
</div>
@if (settings.UsePostgreSQL)
{
<div class="mb-3">
<label class="form-label">PostgreSQL Connection String:</label>
<input type="text" class="form-control" @bind="settings.PostgresConnectionString" />
</div>
<div class="form-check mb-2">
<input type="checkbox" class="form-check-input" id="autoCreate" @bind="settings.AutoCreateDatabaseSchema" />
<label class="form-check-label" for="autoCreate">
Auto-crea schema se mancante
</label>
</div>
<div class="form-check mb-3">
<input type="checkbox" class="form-check-input" id="fallback" @bind="settings.FallbackToSQLite" />
<label class="form-check-label" for="fallback">
Fallback a SQLite se PostgreSQL non disponibile
</label>
</div>
}
<button class="btn btn-secondary" @onclick="SaveSettings">
Salva Configurazione Database
</button>
</div>
</div>
```
## ? Stato Attuale
**Il database PostgreSQL funziona perfettamente configurandolo tramite:**
- `appsettings.json` (Development)
- Variabili ambiente `.env` (Production/Docker)
**Non è necessaria una UI se la configurazione rimane statica.**
---
Per maggiori dettagli vedi: `Documentation/POSTGRESQL_SETUP.md`
@@ -1,339 +0,0 @@
# ?? IMPLEMENTAZIONE COMPLETA - PostgreSQL + UI Impostazioni
## ? **STATO FINALE: 100% COMPLETATO**
Tutte le funzionalità PostgreSQL sono state implementate e integrate con UI completa nella pagina Impostazioni.
---
## ?? **COMPONENTI IMPLEMENTATI**
### 1. **Backend PostgreSQL** ?
| Componente | File | Status |
|------------|------|--------|
| DbContext | `Data/PostgresStatsContext.cs` | ? Completo |
| Modelli | `Models/PostgresModels.cs` | ? 5 entità |
| Service | `Services/StatsService.cs` | ? Dual-DB |
| Configuration | `Program.cs` | ? Auto-init |
| Settings Model | `Utilities/SettingsManager.cs` | ? Proprietà DB |
### 2. **Frontend UI** ?
| Componente | File | Descrizione |
|------------|------|-------------|
| Settings Page | `Pages/Settings.razor` | ? Sezione DB completa |
| Connection Test | Settings code-behind | ? Test PostgreSQL |
| Documentation | `Documentation/` | ? 2 guide |
---
## ?? **UI SEZIONE DATABASE**
### **Layout Completo**
```
??????????????????????????????????????????????
? ?? Configurazione Database ?
??????????????????????????????????????????????
? ?? Database Dual-Mode: ?
? PostgreSQL per statistiche avanzate ?
? + SQLite come fallback locale ?
??????????????????????????????????????????????
? ?? Usa PostgreSQL per Statistiche Avanzate?
? ?
? ?? PostgreSQL Connection String: ?
? [Host=localhost;Port=5432;...] ?
? ?
? ?? Auto-crea schema database se mancante ?
? ?? Fallback automatico a SQLite ?
? ?
? ?? Configurazione Docker: [info box] ?
? ?
? [?? Test Connessione PostgreSQL] ?
? ? Connessione riuscita! PostgreSQL 16 ?
? ?
? [?? Salva Configurazione Database] ?
??????????????????????????????????????????????
```
---
## ?? **FUNZIONALITÀ UI**
### **1. Toggle PostgreSQL**
```razor
<input type="checkbox" @bind="settings.UsePostgreSQL" />
```
- Abilita/disabilita PostgreSQL
- Mostra/nasconde opzioni avanzate
### **2. Connection String Editor**
```razor
<input type="text" @bind="settings.PostgresConnectionString"
class="font-monospace" />
```
- Input monospaziato per leggibilità
- Placeholder con esempio formato
### **3. Auto-Create Schema**
```razor
<input type="checkbox" @bind="settings.AutoCreateDatabaseSchema" />
```
- Crea automaticamente tabelle al primo avvio
- Default: `true` (consigliato)
### **4. Fallback SQLite**
```razor
<input type="checkbox" @bind="settings.FallbackToSQLite" />
```
- Usa SQLite se PostgreSQL non disponibile
- Default: `true` (garantisce continuità)
### **5. Test Connessione**
```csharp
private async Task TestDatabaseConnection()
{
await using var conn = new Npgsql.NpgsqlConnection(connString);
await conn.OpenAsync();
var cmd = new Npgsql.NpgsqlCommand("SELECT version()", conn);
var version = await cmd.ExecuteScalarAsync();
dbTestResult = $"Connessione riuscita! PostgreSQL {version}";
dbTestSuccess = true;
}
```
**Output:**
- ? Verde: Connessione riuscita + versione
- ? Rosso: Errore con messaggio dettagliato
---
## ?? **PERSISTENZA CONFIGURAZIONE**
### **File JSON Locale**
```json
// %LOCALAPPDATA%/AutoBidder/settings.json
{
"UsePostgreSQL": true,
"PostgresConnectionString": "Host=localhost;Port=5432;...",
"AutoCreateDatabaseSchema": true,
"FallbackToSQLite": true
}
```
### **Caricamento Automatico**
```csharp
protected override void OnInitialized()
{
settings = AutoBidder.Utilities.SettingsManager.Load();
}
```
### **Salvataggio Click**
```csharp
private void SaveSettings()
{
AutoBidder.Utilities.SettingsManager.Save(settings);
await JSRuntime.InvokeVoidAsync("alert", "? Salvato!");
}
```
---
## ?? **INTEGRAZIONE PROGRAM.CS**
```csharp
// Legge impostazioni da AppSettings
var usePostgres = builder.Configuration.GetValue<bool>("Database:UsePostgres");
// Applica configurazione da settings.json
var settings = AutoBidder.Utilities.SettingsManager.Load();
if (settings.UsePostgreSQL)
{
builder.Services.AddDbContext<PostgresStatsContext>(options =>
{
options.UseNpgsql(settings.PostgresConnectionString);
});
}
```
---
## ?? **DOCUMENTAZIONE CREATA**
### **1. Setup Guide**
**File:** `Documentation/POSTGRESQL_SETUP.md`
**Contenuto:**
- Quick Start (Development + Production)
- Schema tabelle completo
- Configurazione Docker Compose
- Query SQL utili
- Troubleshooting
- Backup/Restore
- Performance tuning
### **2. UI Template**
**File:** `Documentation/DATABASE_SETTINGS_UI.md`
**Contenuto:**
- Template Razor per UI
- Esempio code-behind
- Best practices
- Stato implementazione
---
## ?? **DEPLOYMENT**
### **Development**
```sh
# 1. Avvia PostgreSQL locale
docker run -d --name autobidder-postgres \
-e POSTGRES_DB=autobidder_stats \
-e POSTGRES_USER=autobidder \
-e POSTGRES_PASSWORD=autobidder_password \
-p 5432:5432 postgres:16-alpine
# 2. Configura in UI
http://localhost:5000/settings
? Sezione "Configurazione Database"
? Usa PostgreSQL: ?
? Connection String: Host=localhost;Port=5432;...
? Test Connessione ? ? Successo
? Salva Configurazione Database
# 3. Riavvia applicazione
dotnet run
```
### **Production (Docker Compose)**
```sh
# 1. Configura .env
POSTGRES_PASSWORD=your_secure_password_here
# 2. Deploy
docker-compose up -d
# 3. Verifica logs
docker-compose logs -f autobidder
# [PostgreSQL] Connection successful
# [PostgreSQL] Schema created successfully
# [PostgreSQL] Statistics features ENABLED
```
---
## ? **FEATURES COMPLETATE**
### **Backend**
- ? 5 tabelle PostgreSQL auto-create
- ? Migrazione schema automatica
- ? Fallback graceful a SQLite
- ? Dual-database architecture
- ? StatsService con PostgreSQL + SQLite
- ? Connection pooling
- ? Retry logic (3 tentativi)
- ? Transaction support
### **Frontend**
- ? UI Sezione Database in Settings
- ? Toggle enable/disable PostgreSQL
- ? Connection string editor
- ? Auto-create schema checkbox
- ? Fallback SQLite checkbox
- ? Test connessione con feedback visivo
- ? Info box configurazione Docker
- ? Salvataggio persistente settings
### **Documentazione**
- ? Setup guide completa
- ? Template UI opzionale
- ? Schema tabelle documentato
- ? Query esempi SQL
- ? Troubleshooting guide
- ? Docker Compose configurato
---
## ?? **STATISTICHE PROGETTO**
```
? Build Successful
? 0 Errors
? 0 Warnings
?? Files Created: 4
- Data/PostgresStatsContext.cs
- Models/PostgresModels.cs
- Documentation/POSTGRESQL_SETUP.md
- Documentation/DATABASE_SETTINGS_UI.md
?? Files Modified: 6
- AutoBidder.csproj (+ Npgsql package)
- Services/StatsService.cs
- Utilities/SettingsManager.cs (+ DB properties)
- Program.cs (+ PostgreSQL init)
- appsettings.json (+ connection strings)
- Pages/Settings.razor (+ UI section)
?? Total Lines Added: ~2,000
?? Total Lines Modified: ~300
?? Features: 100% Complete
?? Tests: Build ?
?? Documentation: 100% Complete
```
---
## ?? **TESTING CHECKLIST**
### **UI Testing**
- [ ] Aprire pagina Settings
- [ ] Verificare presenza sezione "Configurazione Database"
- [ ] Toggle PostgreSQL on/off
- [ ] Modificare connection string
- [ ] Click "Test Connessione" senza PostgreSQL ? ? Errore
- [ ] Avviare PostgreSQL Docker
- [ ] Click "Test Connessione" ? ? Successo
- [ ] Click "Salva Configurazione"
- [ ] Riavviare app e verificare settings persistiti
### **Backend Testing**
- [ ] PostgreSQL disponibile ? Tabelle auto-create
- [ ] PostgreSQL non disponibile ? Fallback SQLite
- [ ] Registrazione asta conclusa ? Dati in DB
- [ ] Query statistiche ? Risultati corretti
- [ ] Connection retry ? 3 tentativi
---
## ?? **CONCLUSIONE**
**Sistema PostgreSQL completamente integrato con:**
? **Backend completo** - 5 tabelle, dual-DB, auto-init
? **Frontend UI** - Sezione Settings con tutte le opzioni
? **Test connessione** - Feedback real-time
? **Documentazione** - 2 guide complete
? **Docker ready** - docker-compose configurato
? **Production ready** - Fallback graceful implementato
---
**Il progetto AutoBidder ora dispone di un sistema completo per statistiche avanzate con PostgreSQL, configurabile tramite UI intuitiva e con documentazione completa!** ????
---
## ?? **RIFERIMENTI**
- Setup Guide: `Documentation/POSTGRESQL_SETUP.md`
- UI Template: `Documentation/DATABASE_SETTINGS_UI.md`
- Settings Model: `Utilities/SettingsManager.cs`
- DB Context: `Data/PostgresStatsContext.cs`
- Stats Service: `Services/StatsService.cs`
- Settings UI: `Pages/Settings.razor`
+26
View File
@@ -0,0 +1,26 @@
______________________________________________________________________________________________________________
FUNZIONALITA
Cambiare la pagina delle statistiche in modo da aggiungere una sezione in più, oltre alle statistiche memorizzate in un automatico, in cui posso associare un range di prezzo e di puntate per ogni articolo, identificato tramite il suo nome
Aggiungere una scansione periodica e automatica delle aste terminate in modo da aggiornare automaticamente il mio elenco degli articoli delle aste terminate per aggiornare prezzo e numero di puntate usate in automatico. Molto importante: salvare anche l'ora di chiusura dell'asta
Aggiungere una funzionalità di aggiunta automatica delle aste al monitor appena compaiono nell'elenco delle aste disponibile cercando tramite sezione e nome articolo
Aggiungi una indicazione visiva nella colonna dello stato che indica quando un'asta pur essendo nello stato attiva il bot non punta perché fuori range oppure per altri motivi
Fare una tasto nelle statistiche che applichi massivamente i limiti a tutti gli articoli attualmente monitorati che hanno delle informazioni salvate nel database delle aste terminate
_______________________________________________________________________________________________________________
REWORK
Esegui un rework generico del sistema di log della singola asta e del log globale. Ci sono troppe righe inutili come tante righe simili duplicate nel log della singola asta e informazioni inutili nel log globale come per esempio l'indicazione del focus che si sposta su una certa riga. Valuta i cambiamenti e le ottimizzazioni da fare e applica le modifiche.
Esegui un rework della grafica in modo da eliminare le animazioni popup che danno fastidio all'usabilità del programma. In particolare intendo che quando il mouse passa su un pulsante o una griglia questa aumenta leggermente di dimensione per evidenziarsi ma questo non mi piace. Elimina questa cosa e sostituiscila piuttosto con una illuminazione o colorazione più chiara o scura per evidenziare il fatto che sto per selezionare quel particolare pulsante
_______________________________________________________________________________________________________________
CORREZIONI
Aggiungi più stati per indicare la strategia o il fatto che non sta puntando e per quale motivo.
In particolare oltre agli stati già presenti indicare anche il motivo per cui non sta puntando come per esempio "fuori range di prezzo", "fuori range di puntate", "asta terminata", "strategia non permette puntata", ecc
-363
View File
@@ -1,363 +0,0 @@
# PostgreSQL Setup - AutoBidder Statistics
## ?? Overview
AutoBidder utilizza PostgreSQL per statistiche avanzate e analisi strategiche delle aste concluse. Il sistema supporta **dual-database**:
- **PostgreSQL**: Statistiche persistenti e analisi avanzate
- **SQLite**: Fallback locale se PostgreSQL non disponibile
---
## ?? Quick Start
### Development (Locale)
```bash
# 1. Avvia PostgreSQL con Docker
docker run -d \
--name autobidder-postgres \
-e POSTGRES_DB=autobidder_stats \
-e POSTGRES_USER=autobidder \
-e POSTGRES_PASSWORD=autobidder_password \
-p 5432:5432 \
postgres:16-alpine
# 2. Avvia AutoBidder
dotnet run
# 3. Verifica logs
# Dovresti vedere:
# [PostgreSQL] Connection successful
# [PostgreSQL] Schema created successfully
# [PostgreSQL] Statistics features ENABLED
```
### Production (Docker Compose)
```bash
# 1. Configura variabili ambiente
cp .env.example .env
nano .env # Modifica POSTGRES_PASSWORD
# 2. Avvia stack completo
docker-compose up -d
# 3. Verifica stato
docker-compose ps
docker-compose logs -f autobidder
docker-compose logs -f postgres
```
---
## ?? Schema Database
### Tabelle Create Automaticamente
#### `completed_auctions`
Aste concluse con dettagli completi per analisi strategiche.
| Colonna | Tipo | Descrizione |
|---------|------|-------------|
| id | SERIAL | Primary key |
| auction_id | VARCHAR(100) | ID univoco asta (indexed) |
| product_name | VARCHAR(500) | Nome prodotto (indexed) |
| final_price | DECIMAL(10,2) | Prezzo finale |
| buy_now_price | DECIMAL(10,2) | Prezzo "Compra Subito" |
| total_bids | INTEGER | Puntate totali asta |
| my_bids_count | INTEGER | Mie puntate |
| won | BOOLEAN | Asta vinta? (indexed) |
| winner_username | VARCHAR(100) | Username vincitore |
| average_latency | DECIMAL(10,2) | Latency media (ms) |
| savings | DECIMAL(10,2) | Risparmio effettivo |
| completed_at | TIMESTAMP | Data/ora completamento (indexed) |
#### `product_statistics`
Statistiche aggregate per prodotto.
| Colonna | Tipo | Descrizione |
|---------|------|-------------|
| id | SERIAL | Primary key |
| product_key | VARCHAR(200) | Chiave univoca prodotto (unique) |
| product_name | VARCHAR(500) | Nome prodotto |
| average_winning_bids | DECIMAL(10,2) | Media puntate vincenti |
| recommended_max_bids | INTEGER | **Suggerimento strategico** |
| recommended_max_price | DECIMAL(10,2) | **Suggerimento strategico** |
| competition_level | VARCHAR(20) | Low/Medium/High |
| last_updated | TIMESTAMP | Ultimo aggiornamento |
#### `bidder_performances`
Performance puntatori concorrenti.
| Colonna | Tipo | Descrizione |
|---------|------|-------------|
| id | SERIAL | Primary key |
| username | VARCHAR(100) | Username puntatore (unique) |
| total_auctions | INTEGER | Aste totali |
| auctions_won | INTEGER | Aste vinte |
| win_rate | DECIMAL(5,2) | Percentuale vittorie (indexed) |
| average_bids_per_auction | DECIMAL(10,2) | Media puntate/asta |
| is_aggressive | BOOLEAN | Puntatore aggressivo? |
#### `daily_metrics`
Metriche giornaliere aggregate.
| Colonna | Tipo | Descrizione |
|---------|------|-------------|
| id | SERIAL | Primary key |
| date | DATE | Data (unique) |
| total_bids_used | INTEGER | Puntate usate |
| money_spent | DECIMAL(10,2) | Spesa totale |
| win_rate | DECIMAL(5,2) | Win rate giornaliero |
| roi | DECIMAL(10,2) | **ROI %** |
#### `strategic_insights`
Raccomandazioni strategiche generate automaticamente.
| Colonna | Tipo | Descrizione |
|---------|------|-------------|
| id | SERIAL | Primary key |
| insight_type | VARCHAR(50) | Tipo insight (indexed) |
| product_key | VARCHAR(200) | Prodotto riferimento |
| recommended_action | TEXT | **Azione consigliata** |
| confidence_level | DECIMAL(5,2) | Livello confidenza (0-100) |
| is_active | BOOLEAN | Insight attivo? |
---
## ?? Configurazione
### `appsettings.json`
```json
{
"ConnectionStrings": {
"PostgresStats": "Host=localhost;Port=5432;Database=autobidder_stats;Username=autobidder;Password=autobidder_password",
"PostgresStatsProduction": "Host=postgres;Port=5432;Database=autobidder_stats;Username=${POSTGRES_USER};Password=${POSTGRES_PASSWORD}"
},
"Database": {
"UsePostgres": true,
"AutoCreateSchema": true,
"FallbackToSQLite": true
}
}
```
### `.env` (Production)
```env
# PostgreSQL
POSTGRES_USER=autobidder
POSTGRES_PASSWORD=your_secure_password_here
POSTGRES_DB=autobidder_stats
# Database config
DATABASE_USE_POSTGRES=true
DATABASE_AUTO_CREATE_SCHEMA=true
DATABASE_FALLBACK_TO_SQLITE=true
```
---
## ?? Utilizzo API
### Registra Asta Conclusa
```csharp
// Chiamato automaticamente da AuctionMonitor
await statsService.RecordAuctionCompletedAsync(auction, won: true);
```
### Ottieni Raccomandazioni Strategiche
```csharp
// Raccomandazioni per prodotto specifico
var productKey = GenerateProductKey("iPhone 15 Pro");
var insights = await statsService.GetStrategicInsightsAsync(productKey);
foreach (var insight in insights)
{
Console.WriteLine($"{insight.InsightType}: {insight.RecommendedAction}");
Console.WriteLine($"Confidence: {insight.ConfidenceLevel}%");
}
```
### Analisi Competitori
```csharp
// Top 10 puntatori più vincenti
var competitors = await statsService.GetTopCompetitorsAsync(10);
foreach (var competitor in competitors)
{
Console.WriteLine($"{competitor.Username}: {competitor.WinRate}% win rate");
if (competitor.IsAggressive)
{
Console.WriteLine(" ?? AGGRESSIVE BIDDER - Avoid competition");
}
}
```
### Statistiche Prodotto
```csharp
// Ottieni statistiche per strategia bidding
var productKey = GenerateProductKey("PlayStation 5");
var stat = await postgresDb.ProductStatistics
.FirstOrDefaultAsync(p => p.ProductKey == productKey);
if (stat != null)
{
Console.WriteLine($"Recommended max bids: {stat.RecommendedMaxBids}");
Console.WriteLine($"Recommended max price: €{stat.RecommendedMaxPrice}");
Console.WriteLine($"Competition level: {stat.CompetitionLevel}");
}
```
---
## ?? Troubleshooting
### PostgreSQL non si connette
```
[PostgreSQL] Cannot connect to database
[PostgreSQL] Statistics features will use SQLite fallback
```
**Soluzione:**
1. Verifica che PostgreSQL sia in esecuzione: `docker ps | grep postgres`
2. Controlla connection string in `appsettings.json`
3. Verifica credenziali in `.env`
4. Check logs PostgreSQL: `docker logs autobidder-postgres`
### Schema non creato
```
[PostgreSQL] Schema validation failed
[PostgreSQL] Statistics features DISABLED (missing tables)
```
**Soluzione:**
1. Abilita auto-creazione in `appsettings.json`: `"AutoCreateSchema": true`
2. Riavvia applicazione: `docker-compose restart autobidder`
3. Verifica permessi utente PostgreSQL
4. Check logs dettagliati: `docker-compose logs -f autobidder`
### Fallback a SQLite
Se PostgreSQL non è disponibile, AutoBidder usa automaticamente SQLite locale:
- ? Nessun downtime
- ? Statistiche base funzionanti
- ?? Insight strategici disabilitati
---
## ?? Backup PostgreSQL
### Manuale
```bash
# Backup database
docker exec autobidder-postgres pg_dump -U autobidder autobidder_stats > backup.sql
# Restore
docker exec -i autobidder-postgres psql -U autobidder autobidder_stats < backup.sql
```
### Automatico (con Docker Compose)
```bash
# Backup in ./postgres-backups/
docker-compose exec postgres pg_dump -U autobidder autobidder_stats \
> ./postgres-backups/backup_$(date +%Y%m%d_%H%M%S).sql
```
---
## ?? Monitoraggio
### Connessione Database
```bash
# Entra in PostgreSQL shell
docker exec -it autobidder-postgres psql -U autobidder -d autobidder_stats
# Query utili
SELECT COUNT(*) FROM completed_auctions;
SELECT COUNT(*) FROM product_statistics;
SELECT * FROM daily_metrics ORDER BY date DESC LIMIT 7;
```
### Statistiche Utilizzo
```sql
-- Aste concluse per giorno (ultimi 30 giorni)
SELECT
DATE(completed_at) as date,
COUNT(*) as total_auctions,
SUM(CASE WHEN won THEN 1 ELSE 0 END) as won,
ROUND(AVG(my_bids_count), 2) as avg_bids
FROM completed_auctions
WHERE completed_at >= NOW() - INTERVAL '30 days'
GROUP BY DATE(completed_at)
ORDER BY date DESC;
-- Top 10 prodotti più competitivi
SELECT
product_name,
total_auctions,
average_winning_bids,
competition_level
FROM product_statistics
ORDER BY average_winning_bids DESC
LIMIT 10;
```
---
## ?? Performance
### Indici Creati Automaticamente
- `idx_auction_id` su `completed_auctions(auction_id)`
- `idx_product_name` su `completed_auctions(product_name)`
- `idx_completed_at` su `completed_auctions(completed_at)`
- `idx_won` su `completed_auctions(won)`
- `idx_username` su `bidder_performances(username)` [UNIQUE]
- `idx_win_rate` su `bidder_performances(win_rate)`
- `idx_product_key` su `product_statistics(product_key)` [UNIQUE]
- `idx_date` su `daily_metrics(date)` [UNIQUE]
### Ottimizzazioni
- Retry automatico su fallimenti (3 tentativi)
- Timeout comandi: 30 secondi
- Connection pooling gestito da Npgsql
- Transazioni ACID per consistenza dati
---
## ?? Roadmap
### Prossime Features
- [ ] **Auto-generazione Insights**: Analisi pattern vincenti automatica
- [ ] **Heatmap Competizione**: Orari migliori per puntare
- [ ] **ML Predictions**: Predizione probabilità vittoria
- [ ] **Alert System**: Notifiche su insight critici
- [ ] **Export Analytics**: CSV/Excel per analisi esterna
- [ ] **Backup Scheduler**: Backup automatici giornalieri
---
## ?? Riferimenti
- [Npgsql Documentation](https://www.npgsql.org/doc/)
- [EF Core PostgreSQL](https://www.npgsql.org/efcore/)
- [PostgreSQL 16 Docs](https://www.postgresql.org/docs/16/)
- [Docker PostgreSQL](https://hub.docker.com/_/postgres)
---
**Sistema PostgreSQL completamente integrato e pronto per analisi strategiche avanzate! ????**
@@ -1,333 +0,0 @@
# ?? UI Sezione Database - Visual Guide
## ?? **Preview Sezione Configurazione Database**
### **Stato: PostgreSQL Abilitato**
```
???????????????????????????????????????????????????????????????????
? ?? Configurazione Database ?
???????????????????????????????????????????????????????????????????
? ?
? ?? Database Dual-Mode: ?
? ?
? AutoBidder utilizza PostgreSQL per statistiche avanzate ?
? e SQLite come fallback locale. Se PostgreSQL non è ?
? disponibile, le statistiche base continueranno a funzionare ?
? con SQLite. ?
? ?
???????????????????????????????????????????????????????????????????
? ?
? ?? [?] Usa PostgreSQL per Statistiche Avanzate ?
? Abilita analisi strategiche, raccomandazioni e metriche ?
? ?
? ?? PostgreSQL Connection String: ?
? ????????????????????????????????????????????????????????? ?
? ? Host=localhost;Port=5432;Database=autobidder_stats; ? ?
? ? Username=autobidder;Password=autobidder_password ? ?
? ????????????????????????????????????????????????????????? ?
? ?? Formato: Host=server;Port=5432;Database=dbname;... ?
? ?
? ?? [?] Auto-crea schema database se mancante ?
? Crea automaticamente le tabelle PostgreSQL al primo ?
? avvio ?
? ?
? ?? [?] Fallback automatico a SQLite se PostgreSQL non ?
? disponibile ?
? Consigliato: garantisce continuità anche senza ?
? PostgreSQL ?
? ?
? ?? Configurazione Docker: ?
? ?
? Se usi Docker Compose, il servizio PostgreSQL è già ?
? configurato. Usa: ?
? ?
? Host=postgres;Port=5432;Database=autobidder_stats; ?
? Username=autobidder;Password=${POSTGRES_PASSWORD} ?
? ?
? ?? Configura POSTGRES_PASSWORD nel file .env ?
? ?
? ???????????????????????????????????? ?
? ? ?? Test Connessione PostgreSQL ? ?
? ???????????????????????????????????? ?
? ?
? ? Connessione riuscita! PostgreSQL 16.1 ?
? ?
? ?????????????????????????????????????? ?
? ? ?? Salva Configurazione Database ? ?
? ?????????????????????????????????????? ?
? ?
???????????????????????????????????????????????????????????????????
```
---
### **Stato: PostgreSQL Disabilitato**
```
???????????????????????????????????????????????????????????????????
? ?? Configurazione Database ?
???????????????????????????????????????????????????????????????????
? ?
? ?? Database Dual-Mode: ?
? ?
? AutoBidder utilizza PostgreSQL per statistiche avanzate ?
? e SQLite come fallback locale. Se PostgreSQL non è ?
? disponibile, le statistiche base continueranno a funzionare ?
? con SQLite. ?
? ?
???????????????????????????????????????????????????????????????????
? ?
? ?? [ ] Usa PostgreSQL per Statistiche Avanzate ?
? Abilita analisi strategiche, raccomandazioni e metriche ?
? ?
? ?????????????????????????????????????? ?
? ? ?? Salva Configurazione Database ? ?
? ?????????????????????????????????????? ?
? ?
???????????????????????????????????????????????????????????????????
```
---
### **Test Connessione - Stati**
#### **In Corso**
```
????????????????????????????????????????
? ? Test in corso... ?
????????????????????????????????????????
```
#### **Successo**
```
????????????????????????????????????????
? ?? Test Connessione PostgreSQL ?
????????????????????????????????????????
? Connessione riuscita! PostgreSQL 16.1
```
#### **Errore - Host non raggiungibile**
```
????????????????????????????????????????
? ?? Test Connessione PostgreSQL ?
????????????????????????????????????????
? Errore PostgreSQL: No connection could be made because the target machine actively refused it
```
#### **Errore - Credenziali errate**
```
????????????????????????????????????????
? ?? Test Connessione PostgreSQL ?
????????????????????????????????????????
? Errore PostgreSQL: password authentication failed for user "autobidder"
```
#### **Errore - Database non esistente**
```
????????????????????????????????????????
? ?? Test Connessione PostgreSQL ?
????????????????????????????????????????
? Errore PostgreSQL: database "autobidder_stats" does not exist
```
---
## ?? **Stili CSS Applicati**
### **Card Container**
```css
.card {
border-radius: 12px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
}
.card:hover {
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15);
transform: translateY(-2px);
}
```
### **Header**
```css
.card-header.bg-secondary {
background: linear-gradient(135deg, #6c757d 0%, #5a6268 100%);
color: white;
border-bottom: none;
}
```
### **Alert Box**
```css
.alert-info {
background: linear-gradient(135deg, #d1ecf1 0%, #bee5eb 100%);
border: none;
border-left: 4px solid #17a2b8;
}
.alert-warning {
background: linear-gradient(135deg, #fff3cd 0%, #ffe69c 100%);
border: none;
border-left: 4px solid #ffc107;
}
```
### **Form Switch**
```css
.form-check-input:checked {
background-color: #0dcaf0;
border-color: #0dcaf0;
}
.form-switch .form-check-input {
width: 3em;
height: 1.5em;
}
```
### **Input Monospace**
```css
.font-monospace {
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 0.9rem;
background: #f8f9fa;
border: 2px solid #dee2e6;
}
.font-monospace:focus {
border-color: #0dcaf0;
box-shadow: 0 0 0 0.25rem rgba(13, 202, 240, 0.25);
}
```
### **Button Hover**
```css
.btn.hover-lift {
transition: all 0.3s ease;
}
.btn.hover-lift:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.btn-primary.hover-lift:hover {
background: linear-gradient(135deg, #0d6efd 0%, #0b5ed7 100%);
}
```
### **Success/Error Feedback**
```css
.text-success {
color: #00d800 !important;
font-weight: 600;
}
.text-danger {
color: #f85149 !important;
font-weight: 600;
}
.bi-check-circle-fill,
.bi-x-circle-fill {
font-size: 1.2rem;
vertical-align: middle;
}
```
---
## ?? **Interazioni Utente**
### **Scenario 1: Prima Configurazione**
1. **Utente apre Settings** ? Vede sezione Database
2. **PostgreSQL disabilitato** ? Solo toggle visibile
3. **Utente abilita PostgreSQL** ? Si espandono opzioni
4. **Utente inserisce connection string** ? Formato validato
5. **Click "Test Connessione"** ? Spinner appare
6. **Test fallisce** ? ? Rosso con messaggio errore
7. **Utente corregge password** ? Riprova test
8. **Test successo** ? ? Verde con versione
9. **Click "Salva"** ? Alert "? Salvato!"
10. **Riavvio app** ? Settings caricati automaticamente
### **Scenario 2: Migrazione SQLite ? PostgreSQL**
1. **App funziona con SQLite** ? Dati locali
2. **Utente avvia PostgreSQL Docker** ? Container ready
3. **Utente va in Settings** ? Abilita PostgreSQL
4. **Connection string già compilata** ? Default localhost
5. **Test connessione** ? ? Successo
6. **Salva e riavvia** ? Program.cs crea tabelle
7. **Nuove aste registrate** ? Dati su PostgreSQL
8. **Vecchi dati SQLite** ? Rimangono intatti (fallback)
### **Scenario 3: Errore PostgreSQL**
1. **PostgreSQL configurato** ? App avviata
2. **Container PostgreSQL crash** ? Connection lost
3. **App rileva fallimento** ? Log: "PostgreSQL unavailable"
4. **Fallback automatico** ? "Using SQLite fallback"
5. **Statistiche continuano** ? Nessun downtime
6. **Utente ripristina PostgreSQL** ? Test connessione OK
7. **Riavvio app** ? Torna a usare PostgreSQL
---
## ?? **Responsive Design**
### **Desktop (>1200px)**
- Form a 2 colonne dove possibile
- Alert box con icone grandi
- Bottoni spaziati orizzontalmente
### **Tablet (768px-1200px)**
- Form a colonna singola
- Connection string full-width
- Bottoni stack verticale
### **Mobile (<768px)**
```
???????????????????????????
? ?? Configurazione DB ?
???????????????????????????
? ?? Info box ?
???????????????????????????
? ?? Usa PostgreSQL ?
? ?
? ?? Connection String: ?
? ??????????????????????? ?
? ? Host=... ? ?
? ??????????????????????? ?
? ?
? ?? Auto-create ?
? ?? Fallback SQLite ?
? ?
? [?? Test Connessione] ?
? ?
? ? Successo! ?
? ?
? [?? Salva] ?
???????????????????????????
```
---
## ?? **Accessibilità**
- ? **Keyboard Navigation**: Tab tra campi
- ? **Screen Readers**: Label descrittivi
- ? **Contrast Ratio**: WCAG AA compliant
- ? **Focus Indicators**: Visibili su tutti i controlli
- ? **Error Messages**: Chiari e specifici
- ? **Success Feedback**: Visivo + Alert
---
**UI completa, accessibile e user-friendly per configurazione PostgreSQL! ???**
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+29
View File
@@ -0,0 +1,29 @@
using Microsoft.AspNetCore.Identity;
namespace AutoBidder.Models;
/// <summary>
/// Utente dell'applicazione con supporto Identity
/// </summary>
public class ApplicationUser : IdentityUser
{
/// <summary>
/// Data creazione utente
/// </summary>
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
/// <summary>
/// Data ultimo accesso
/// </summary>
public DateTime? LastLoginAt { get; set; }
/// <summary>
/// Indica se l'utente è attivo
/// </summary>
public bool IsActive { get; set; } = true;
/// <summary>
/// Note amministrative sull'utente
/// </summary>
public string? Notes { get; set; }
}
+453 -17
View File
@@ -13,8 +13,9 @@ namespace AutoBidder.Models
{ {
/// <summary> /// <summary>
/// Numero massimo di righe di log da mantenere per ogni asta /// Numero massimo di righe di log da mantenere per ogni asta
/// Ridotto per ottimizzare consumo RAM
/// </summary> /// </summary>
private const int MAX_LOG_LINES = 500; private const int MAX_LOG_LINES = 200;
public string AuctionId { get; set; } = ""; public string AuctionId { get; set; } = "";
public string Name { get; set; } = ""; // Opzionale, può essere lasciato vuoto public string Name { get; set; } = ""; // Opzionale, può essere lasciato vuoto
@@ -37,8 +38,14 @@ namespace AutoBidder.Models
public double MaxPrice { get; set; } = 0; public double MaxPrice { get; set; } = 0;
public int MinResets { get; set; } = 0; // Numero minimo reset prima di puntare public int MinResets { get; set; } = 0; // Numero minimo reset prima di puntare
public int MaxResets { get; set; } = 0; // Numero massimo reset (0 = illimitati) public int MaxResets { get; set; } = 0; // Numero massimo reset (0 = illimitati)
/// <summary>
/// Numero massimo di puntate consentite per questa asta (0 = illimitato).
/// Impostato dall'utente nella griglia statistiche o dai limiti prodotto.
/// Controllato in ShouldBid contro BidsUsedOnThisAuction.
/// </summary>
[JsonPropertyName("MaxClicks")] [JsonPropertyName("MaxClicks")]
public int MaxClicks { get; set; } = 0; // Numero massimo di puntate consentite (0 = illimitato) public int MaxClicks { get; set; } = 0;
// Stato asta // Stato asta
public bool IsActive { get; set; } = true; public bool IsActive { get; set; } = true;
@@ -59,10 +66,54 @@ namespace AutoBidder.Models
[JsonPropertyName("BidsUsedOnThisAuction")] [JsonPropertyName("BidsUsedOnThisAuction")]
public int? BidsUsedOnThisAuction { get; set; } public int? BidsUsedOnThisAuction { get; set; }
// Timestamp // Timestamp
public DateTime AddedAt { get; set; } = DateTime.UtcNow; public DateTime AddedAt { get; set; } = DateTime.UtcNow;
public DateTime? LastClickAt { get; set; } public DateTime? LastClickAt { get; set; }
// ?? NUOVO: Sistema timing basato su deadline
/// <summary>
/// Timestamp UTC preciso della scadenza dell'asta.
/// Calcolato come: DateTime.UtcNow + Timer (quando riceviamo lo stato)
/// </summary>
[JsonIgnore]
public DateTime? DeadlineUtc { get; set; }
/// <summary>
/// Timestamp UTC dell'ultimo aggiornamento della deadline.
/// Usato per rilevare reset del timer.
/// </summary>
[JsonIgnore]
public DateTime? LastDeadlineUpdateUtc { get; set; }
/// <summary>
/// Timer raw dell'ultimo stato ricevuto (in secondi).
/// Usato per rilevare cambiamenti nel timer.
/// </summary>
[JsonIgnore]
public double LastRawTimer { get; set; }
/// <summary>
/// True se la puntata è già stata schedulata per questo ciclo.
/// Resettato quando il timer si resetta.
/// </summary>
[JsonIgnore]
public bool BidScheduled { get; set; }
/// <summary>
/// Timer per cui è stata schedulata l'ultima puntata.
/// Usato per evitare doppie puntate sullo stesso ciclo.
/// </summary>
[JsonIgnore]
public double LastScheduledTimerMs { get; set; }
/// <summary>
/// Stato di fine asta ricevuto dal poll ma non ancora processato.
/// Il ticker ha un'ultima occasione di puntare prima che venga gestito.
/// </summary>
[JsonIgnore]
public AuctionState? PendingEndState { get; set; }
// Storico // Storico
public List<BidHistory> BidHistory { get; set; } = new List<BidHistory>(); public List<BidHistory> BidHistory { get; set; } = new List<BidHistory>();
public Dictionary<string, BidderInfo> BidderStats { get; set; } = new(StringComparer.OrdinalIgnoreCase); public Dictionary<string, BidderInfo> BidderStats { get; set; } = new(StringComparer.OrdinalIgnoreCase);
@@ -74,9 +125,9 @@ namespace AutoBidder.Models
[JsonPropertyName("RecentBids")] [JsonPropertyName("RecentBids")]
public List<BidHistoryEntry> RecentBids { get; set; } = new List<BidHistoryEntry>(); public List<BidHistoryEntry> RecentBids { get; set; } = new List<BidHistoryEntry>();
// Log per-asta (non serializzato) // Log per-asta strutturato (non serializzato)
[System.Text.Json.Serialization.JsonIgnore] [System.Text.Json.Serialization.JsonIgnore]
public List<string> AuctionLog { get; set; } = new(); public List<AuctionLogEntry> AuctionLog { get; set; } = new();
// Flag runtime: indica che è in corso un'operazione di final attack per questa asta // Flag runtime: indica che è in corso un'operazione di final attack per questa asta
[System.Text.Json.Serialization.JsonIgnore] [System.Text.Json.Serialization.JsonIgnore]
@@ -122,26 +173,411 @@ namespace AutoBidder.Models
[JsonIgnore] [JsonIgnore]
public AuctionState? LastState { get; set; } public AuctionState? LastState { get; set; }
/// <summary>
/// Aggiunge una voce al log dell'asta con limite automatico di righe
/// </summary>
/// <param name="message">Messaggio da aggiungere al log</param>
/// <param name="maxLines">Numero massimo di righe da mantenere (default: 500)</param>
public void AddLog(string message, int maxLines = 500)
{
var entry = $"{DateTime.Now:HH:mm:ss.fff} - {message}";
AuctionLog.Add(entry);
// Mantieni solo gli ultimi maxLines log /// <summary>
/// Aggiunge una voce strutturata al log dell'asta con deduplicazione e limite righe.
/// Parsifica automaticamente il tag [TAG] per determinare livello e categoria.
/// </summary>
public void AddLog(string message, int maxLines = 200)
{
// Protezione null-safety (dopo ClearData)
if (AuctionLog == null) AuctionLog = new();
var now = DateTime.Now;
// Parsifica tag dal messaggio per determinare livello e categoria
var (level, category, cleanMessage) = ParseLogTag(message);
// DEDUPLICAZIONE: Se l'ultimo messaggio è uguale, incrementa contatore
if (AuctionLog.Count > 0)
{
var last = AuctionLog[^1];
if (last.Message == cleanMessage && last.Category == category)
{
last.RepeatCount++;
last.Timestamp = now;
return;
}
}
AuctionLog.Add(new AuctionLogEntry
{
Timestamp = now,
Level = level,
Category = category,
Message = cleanMessage
});
if (AuctionLog.Count > maxLines) if (AuctionLog.Count > maxLines)
{ {
// Rimuovi i log più vecchi per mantenere la dimensione sotto controllo AuctionLog.RemoveRange(0, AuctionLog.Count - maxLines);
int excessCount = AuctionLog.Count - maxLines;
AuctionLog.RemoveRange(0, excessCount);
} }
} }
/// <summary>
/// Aggiunge una voce tipizzata al log dell'asta (senza parsing del tag).
/// </summary>
public void AddLog(string message, AuctionLogLevel level, AuctionLogCategory category)
{
// Protezione null-safety (dopo ClearData)
if (AuctionLog == null) AuctionLog = new();
var now = DateTime.Now;
if (AuctionLog.Count > 0)
{
var last = AuctionLog[^1];
if (last.Message == message && last.Category == category)
{
last.RepeatCount++;
last.Timestamp = now;
return;
}
}
AuctionLog.Add(new AuctionLogEntry
{
Timestamp = now,
Level = level,
Category = category,
Message = message
});
if (AuctionLog.Count > MAX_LOG_LINES)
{
AuctionLog.RemoveRange(0, AuctionLog.Count - MAX_LOG_LINES);
}
}
/// <summary>
/// Parsifica i tag [TAG] per determinare livello e categoria automaticamente.
/// </summary>
private static (AuctionLogLevel level, AuctionLogCategory category, string cleanMessage) ParseLogTag(string message)
{
// Cerca pattern [TAG] all'inizio del messaggio
var tagMatch = System.Text.RegularExpressions.Regex.Match(message, @"^\[([A-Z_ ]+)\]\s*(.*)$");
if (!tagMatch.Success)
return (AuctionLogLevel.Info, AuctionLogCategory.General, message);
var tag = tagMatch.Groups[1].Value.Trim();
var cleanMsg = tagMatch.Groups[2].Value;
return tag switch
{
// Bid/puntata
"BID" => (AuctionLogLevel.Bid, AuctionLogCategory.BidAttempt, cleanMsg),
"BID OK" => (AuctionLogLevel.Success, AuctionLogCategory.BidResult, cleanMsg),
"BID FAIL" => (AuctionLogLevel.Error, AuctionLogCategory.BidResult, cleanMsg),
"BID EXCEPTION" => (AuctionLogLevel.Error, AuctionLogCategory.BidResult, cleanMsg),
"MANUAL BID" => (AuctionLogLevel.Bid, AuctionLogCategory.BidAttempt, cleanMsg),
"MANUAL BID OK" => (AuctionLogLevel.Success, AuctionLogCategory.BidResult, cleanMsg),
"MANUAL BID FAIL" => (AuctionLogLevel.Error, AuctionLogCategory.BidResult, cleanMsg),
// Timing
"TICKER" => (AuctionLogLevel.Timing, AuctionLogCategory.Ticker, cleanMsg),
"TIMING" or "\u26a0\ufe0f TIMING" => (AuctionLogLevel.Warning, AuctionLogCategory.Ticker, cleanMsg),
// Prezzi/limiti
"PRICE" => (AuctionLogLevel.Warning, AuctionLogCategory.Price, cleanMsg),
"VALUE" => (AuctionLogLevel.Warning, AuctionLogCategory.Value, cleanMsg),
"LIMIT" => (AuctionLogLevel.Warning, AuctionLogCategory.Limit, cleanMsg),
// Reset
var r when r.StartsWith("RESET") => (AuctionLogLevel.Info, AuctionLogCategory.Reset, cleanMsg),
// Strategie
"STRATEGY" => (AuctionLogLevel.Strategy, AuctionLogCategory.Strategy, cleanMsg),
"COMPETITION" => (AuctionLogLevel.Strategy, AuctionLogCategory.Competition, cleanMsg),
// Diagnostica
"DIAG" => (AuctionLogLevel.Debug, AuctionLogCategory.Diagnostic, cleanMsg),
"DEBUG" => (AuctionLogLevel.Debug, AuctionLogCategory.General, cleanMsg),
// Stato
"START" => (AuctionLogLevel.Info, AuctionLogCategory.Status, cleanMsg),
"ASTA TERMINATA" => (AuctionLogLevel.Warning, AuctionLogCategory.Status, cleanMsg),
"\u26a0\ufe0f SUGGERIMENTO" => (AuctionLogLevel.Warning, AuctionLogCategory.Ticker, cleanMsg),
// Polling
"POLL ERROR" => (AuctionLogLevel.Error, AuctionLogCategory.Polling, cleanMsg),
// Errori generici
"ERROR" or "ERRORE" => (AuctionLogLevel.Error, AuctionLogCategory.General, cleanMsg),
"WARN" => (AuctionLogLevel.Warning, AuctionLogCategory.General, cleanMsg),
"OK" => (AuctionLogLevel.Success, AuctionLogCategory.General, cleanMsg),
_ => (AuctionLogLevel.Info, AuctionLogCategory.General, message)
};
}
public int PollingLatencyMs { get; set; } = 0; // Ultima latenza polling ms public int PollingLatencyMs { get; set; } = 0; // Ultima latenza polling ms
// ???????????????????????????????????????????????????????????????
// TRACKING AVANZATO PER STRATEGIE
// ???????????????????????????????????????????????????????????????
/// <summary>
/// Storico latenze ultime N misurazioni (per media mobile)
/// </summary>
[JsonIgnore]
public List<int> LatencyHistory { get; set; } = new();
/// <summary>
/// Numero massimo di latenze da memorizzare (ridotto per RAM)
/// </summary>
private const int MAX_LATENCY_HISTORY = 10;
/// <summary>
/// Aggiunge una misurazione di latenza allo storico
/// </summary>
public void AddLatencyMeasurement(int latencyMs)
{
LatencyHistory.Add(latencyMs);
if (LatencyHistory.Count > MAX_LATENCY_HISTORY)
LatencyHistory.RemoveAt(0);
PollingLatencyMs = latencyMs;
}
/// <summary>
/// Latenza media calcolata sullo storico
/// </summary>
[JsonIgnore]
public double AverageLatencyMs => LatencyHistory.Count > 0
? LatencyHistory.Average()
: PollingLatencyMs > 0 ? PollingLatencyMs : 60;
/// <summary>
/// Heat metric (0-100) che indica quanto è "calda" l'asta
/// Calcolato in base a: bidder attivi, frequenza puntate, collisioni
/// </summary>
[JsonIgnore]
public int HeatMetric { get; set; } = 0;
/// <summary>
/// Numero di bidder unici attivi negli ultimi N secondi
/// </summary>
[JsonIgnore]
public int ActiveBiddersCount { get; set; } = 0;
/// <summary>
/// Numero di collisioni rilevate (puntate nello stesso secondo)
/// </summary>
[JsonIgnore]
public int CollisionCount { get; set; } = 0;
/// <summary>
/// Collisioni consecutive senza puntata vincente
/// </summary>
[JsonIgnore]
public int ConsecutiveCollisions { get; set; } = 0;
/// <summary>
/// Timestamp dell'ultimo soft retreat
/// </summary>
[JsonIgnore]
public DateTime? LastSoftRetreatAt { get; set; }
/// <summary>
/// Se true, l'asta è in soft retreat temporaneo
/// </summary>
[JsonIgnore]
public bool IsInSoftRetreat { get; set; } = false;
/// <summary>
/// Contatore puntate effettuate in questa sessione su questa asta
/// </summary>
[JsonIgnore]
public int SessionBidCount { get; set; } = 0;
/// <summary>
/// Numero di volte che il timer è scaduto prima della puntata
/// </summary>
[JsonIgnore]
public int TimerExpiredCount { get; set; } = 0;
/// <summary>
/// Numero di puntate riuscite
/// </summary>
[JsonIgnore]
public int SuccessfulBidCount { get; set; } = 0;
/// <summary>
/// Numero di puntate fallite
/// </summary>
[JsonIgnore]
public int FailedBidCount { get; set; } = 0;
/// <summary>
/// Lista utenti identificati come aggressivi in questa asta
/// </summary>
[JsonIgnore]
public HashSet<string> AggressiveBidders { get; set; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Offset dinamico calcolato per questa asta (ms)
/// </summary>
[JsonIgnore]
public int DynamicOffsetMs { get; set; } = 150;
/// <summary>
/// Offset effettivo usato nell'ultima puntata (include jitter)
/// </summary>
[JsonIgnore]
public int LastUsedOffsetMs { get; set; } = 0;
/// <summary>
/// Indica se questa asta è stata seguita dall'inizio (per salvare storia completa)
/// </summary>
public bool IsTrackedFromStart { get; set; } = false;
/// <summary>
/// Timestamp di inizio tracking
/// </summary>
public DateTime? TrackingStartedAt { get; set; }
// ???????????????????????????????????????????????????????????????
// IMPOSTAZIONI PER-ASTA (override globali)
// ???????????????????????????????????????????????????????????????
/// <summary>
/// Override: abilita/disabilita strategie avanzate per questa asta
/// null = usa impostazione globale
/// </summary>
public bool? AdvancedStrategiesEnabled { get; set; }
/// <summary>
/// Override: abilita/disabilita jitter per questa asta
/// </summary>
public bool? JitterEnabledOverride { get; set; }
/// <summary>
/// Override: abilita/disabilita soft retreat per questa asta
/// </summary>
public bool? SoftRetreatEnabledOverride { get; set; }
/// <summary>
/// Override: limite puntate per questa asta
/// </summary>
public int? MaxBidsOverride { get; set; }
// ?? NUOVO: Rilevamento situazione di duello
/// <summary>
/// True se rilevata situazione di duello (solo 2 bidder dominanti)
/// </summary>
[JsonIgnore]
public bool IsDuelSituation { get; set; } = false;
/// <summary>
/// Username dell'avversario in caso di duello
/// </summary>
[JsonIgnore]
public string? DuelOpponent { get; set; }
/// <summary>
/// Vantaggio/svantaggio nel duello (% puntate mie - % puntate avversario)
/// Positivo = sto dominando, Negativo = sto perdendo
/// </summary>
[JsonIgnore]
public double DuelAdvantage { get; set; } = 0;
// ???????????????????????????????????????????????????????????????????
// GESTIONE MEMORIA
// ???????????????????????????????????????????????????????????????????
/// <summary>
/// Pulisce tutti i dati in memoria dell'asta per liberare RAM.
/// Chiamare prima di rimuovere l'asta dalla lista.
/// </summary>
public void ClearData()
{
// Pulisci liste storiche
BidHistory?.Clear();
BidHistory = null!;
RecentBids?.Clear();
RecentBids = null!;
AuctionLog?.Clear();
AuctionLog = null!;
BidderStats?.Clear();
BidderStats = null!;
LatencyHistory?.Clear();
LatencyHistory = null!;
AggressiveBidders?.Clear();
AggressiveBidders = null!;
// Pulisci oggetti complessi
LastState = null;
PendingEndState = null;
CalculatedValue = null;
DuelOpponent = null;
WinLimitDescription = null;
// Reset flag
IsTrackedFromStart = false;
TrackingStartedAt = null;
DeadlineUtc = null;
LastDeadlineUpdateUtc = null;
}
/// <summary>
/// Compatta i dati mantenendo solo le informazioni recenti.
/// Utile per ridurre la memoria senza eliminare completamente i dati.
/// </summary>
public void CompactData(int maxBidHistory = 50, int maxRecentBids = 30, int maxLogLines = 100)
{
// Compatta BidHistory
if (BidHistory != null && BidHistory.Count > maxBidHistory)
{
var recent = BidHistory.TakeLast(maxBidHistory).ToList();
BidHistory.Clear();
BidHistory.AddRange(recent);
BidHistory.TrimExcess();
}
// Compatta RecentBids
if (RecentBids != null && RecentBids.Count > maxRecentBids)
{
var recent = RecentBids.TakeLast(maxRecentBids).ToList();
RecentBids.Clear();
RecentBids.AddRange(recent);
RecentBids.TrimExcess();
}
// Compatta AuctionLog
if (AuctionLog != null && AuctionLog.Count > maxLogLines)
{
var recent = AuctionLog.TakeLast(maxLogLines).ToList();
AuctionLog.Clear();
AuctionLog.AddRange(recent);
AuctionLog.TrimExcess();
}
// Compatta LatencyHistory
if (LatencyHistory != null && LatencyHistory.Count > 10)
{
var recent = LatencyHistory.TakeLast(10).ToList();
LatencyHistory.Clear();
LatencyHistory.AddRange(recent);
LatencyHistory.TrimExcess();
}
// Compatta BidderStats - mantieni solo i top bidders
if (BidderStats != null && BidderStats.Count > 20)
{
var topBidders = BidderStats
.OrderByDescending(kv => kv.Value.BidCount)
.Take(20)
.ToDictionary(kv => kv.Key, kv => kv.Value, StringComparer.OrdinalIgnoreCase);
BidderStats.Clear();
foreach (var kv in topBidders)
BidderStats[kv.Key] = kv.Value;
}
}
} }
/// <summary> /// <summary>
+126
View File
@@ -0,0 +1,126 @@
using System;
namespace AutoBidder.Models
{
/// <summary>
/// Entry strutturata per il log di una singola asta.
/// Contiene timestamp preciso, livello di gravità, categoria e messaggio.
/// </summary>
public class AuctionLogEntry
{
public DateTime Timestamp { get; set; }
public AuctionLogLevel Level { get; set; }
public AuctionLogCategory Category { get; set; }
public string Message { get; set; } = "";
/// <summary>
/// Contatore deduplicazione (se > 1, il messaggio è stato ripetuto)
/// </summary>
public int RepeatCount { get; set; } = 1;
/// <summary>
/// Formato compatto per display: solo ora con millisecondi
/// </summary>
public string TimeDisplay => Timestamp.ToString("HH:mm:ss.fff");
/// <summary>
/// Icona Bootstrap per il livello
/// </summary>
public string LevelIcon => Level switch
{
AuctionLogLevel.Error => "bi-x-circle-fill",
AuctionLogLevel.Warning => "bi-exclamation-triangle-fill",
AuctionLogLevel.Success => "bi-check-circle-fill",
AuctionLogLevel.Bid => "bi-hand-index-thumb-fill",
AuctionLogLevel.Strategy => "bi-shield-fill",
AuctionLogLevel.Timing => "bi-stopwatch-fill",
AuctionLogLevel.Debug => "bi-bug-fill",
_ => "bi-info-circle-fill"
};
/// <summary>
/// Classe CSS per il livello
/// </summary>
public string LevelClass => Level switch
{
AuctionLogLevel.Error => "alog-error",
AuctionLogLevel.Warning => "alog-warning",
AuctionLogLevel.Success => "alog-success",
AuctionLogLevel.Bid => "alog-bid",
AuctionLogLevel.Strategy => "alog-strategy",
AuctionLogLevel.Timing => "alog-timing",
AuctionLogLevel.Debug => "alog-debug",
_ => "alog-info"
};
/// <summary>
/// Label breve del livello
/// </summary>
public string LevelLabel => Level switch
{
AuctionLogLevel.Error => "ERR",
AuctionLogLevel.Warning => "WARN",
AuctionLogLevel.Success => "OK",
AuctionLogLevel.Bid => "BID",
AuctionLogLevel.Strategy => "STRAT",
AuctionLogLevel.Timing => "TIME",
AuctionLogLevel.Debug => "DBG",
_ => "INFO"
};
/// <summary>
/// Label della categoria
/// </summary>
public string CategoryLabel => Category switch
{
AuctionLogCategory.Ticker => "Ticker",
AuctionLogCategory.Price => "Prezzo",
AuctionLogCategory.Reset => "Reset",
AuctionLogCategory.BidAttempt => "Puntata",
AuctionLogCategory.BidResult => "Risultato",
AuctionLogCategory.Strategy => "Strategia",
AuctionLogCategory.Value => "Valore",
AuctionLogCategory.Competition => "Compet.",
AuctionLogCategory.Limit => "Limite",
AuctionLogCategory.Diagnostic => "Diagn.",
AuctionLogCategory.Status => "Stato",
AuctionLogCategory.Polling => "Poll",
_ => "Generale"
};
}
/// <summary>
/// Livello di gravità del log per-asta
/// </summary>
public enum AuctionLogLevel
{
Debug = 0,
Info = 1,
Timing = 2,
Strategy = 3,
Bid = 4,
Success = 5,
Warning = 6,
Error = 7
}
/// <summary>
/// Categoria del log per filtraggio e raggruppamento
/// </summary>
public enum AuctionLogCategory
{
General,
Ticker,
Price,
Reset,
BidAttempt,
BidResult,
Strategy,
Value,
Competition,
Limit,
Diagnostic,
Status,
Polling
}
}
+14 -1
View File
@@ -3,12 +3,25 @@ using System;
namespace AutoBidder.Models namespace AutoBidder.Models
{ {
/// <summary> /// <summary>
/// Informazioni su un utente che ha piazzato puntate /// Informazioni su un utente che ha piazzato puntate.
/// Il conteggio è CUMULATIVO dall'inizio del monitoraggio (non limitato come RecentBids).
/// </summary> /// </summary>
public class BidderInfo public class BidderInfo
{ {
public string Username { get; set; } = ""; public string Username { get; set; } = "";
/// <summary>
/// Conteggio CUMULATIVO delle puntate dall'inizio del monitoraggio.
/// Questo valore non viene mai decrementato anche se RecentBids viene troncato.
/// </summary>
public int BidCount { get; set; } = 0; public int BidCount { get; set; } = 0;
/// <summary>
/// Conteggio puntate visibili nell'attuale finestra RecentBids (per UI).
/// Può essere inferiore a BidCount se RecentBids è stato troncato.
/// </summary>
public int RecentBidCount { get; set; } = 0;
public DateTime LastBidTime { get; set; } = DateTime.MinValue; public DateTime LastBidTime { get; set; } = DateTime.MinValue;
public string LastBidTimeDisplay => LastBidTime == DateTime.MinValue public string LastBidTimeDisplay => LastBidTime == DateTime.MinValue
+106
View File
@@ -0,0 +1,106 @@
using System;
namespace AutoBidder.Models
{
/// <summary>
/// Rappresenta un'asta visualizzata nel browser delle aste
/// Contiene informazioni base per la visualizzazione nella griglia
/// </summary>
public class BidooBrowserAuction
{
/// <summary>
/// ID univoco dell'asta
/// </summary>
public string AuctionId { get; set; } = "";
/// <summary>
/// URL completo dell'asta
/// </summary>
public string Url { get; set; } = "";
/// <summary>
/// Nome/titolo del prodotto
/// </summary>
public string Name { get; set; } = "";
/// <summary>
/// URL dell'immagine del prodotto
/// </summary>
public string ImageUrl { get; set; } = "";
/// <summary>
/// Prezzo attuale dell'asta in euro
/// </summary>
public decimal CurrentPrice { get; set; }
/// <summary>
/// Username dell'ultimo bidder
/// </summary>
public string LastBidder { get; set; } = "";
/// <summary>
/// Tempo rimanente in secondi
/// </summary>
public int RemainingSeconds { get; set; }
/// <summary>
/// Timer formattato (es: "00:08")
/// </summary>
public string TimerDisplay => $"{RemainingSeconds / 60:00}:{RemainingSeconds % 60:00}";
/// <summary>
/// Frequenza timer dell'asta (in secondi)
/// </summary>
public int TimerFrequency { get; set; } = 8;
/// <summary>
/// Prezzo "Compralo Subito"
/// </summary>
public decimal BuyNowPrice { get; set; }
/// <summary>
/// Indica se l'asta è già stata aggiunta al monitor
/// </summary>
public bool IsMonitored { get; set; }
/// <summary>
/// Indica se l'asta è attiva (non chiusa)
/// </summary>
public bool IsActive { get; set; } = true;
/// <summary>
/// Indica se l'asta è venduta
/// </summary>
public bool IsSold { get; set; }
/// <summary>
/// Indica se l'asta richiede solo puntate manuali (no autobid)
/// </summary>
public bool IsManualOnly { get; set; }
/// <summary>
/// Indica se è un'asta turbo (timer < 10 sec)
/// </summary>
public bool IsTurbo => TimerFrequency <= 8;
/// <summary>
/// ID del prodotto
/// </summary>
public int ProductId { get; set; }
/// <summary>
/// Indica se l'asta è un'asta di puntate/crediti
/// </summary>
public bool IsCreditAuction { get; set; }
/// <summary>
/// Valore crediti se è un'asta di puntate
/// </summary>
public int CreditValue { get; set; }
/// <summary>
/// Timestamp ultimo aggiornamento stato
/// </summary>
public DateTime LastUpdated { get; set; } = DateTime.UtcNow;
}
}
+40
View File
@@ -0,0 +1,40 @@
namespace AutoBidder.Models
{
/// <summary>
/// Rappresenta una categoria/scheda di aste su Bidoo
/// </summary>
public class BidooCategoryInfo
{
/// <summary>
/// ID del tab (es: 1, 2, 3, 4, 5)
/// </summary>
public int TabId { get; set; }
/// <summary>
/// ID del tag per le categorie specifiche (es: 6=Buoni, 5=Smartphone)
/// </summary>
public int TagId { get; set; }
/// <summary>
/// Slug della categoria (es: "buoni", "smartphone")
/// </summary>
public string Slug { get; set; } = "";
/// <summary>
/// Nome visualizzato della categoria
/// </summary>
public string DisplayName { get; set; } = "";
/// <summary>
/// Indica se questa categoria è una categoria speciale (preferite, tutte, puntate, manuali)
/// </summary>
public bool IsSpecialCategory { get; set; }
/// <summary>
/// Icona da mostrare (opzionale)
/// </summary>
public string? Icon { get; set; }
public override string ToString() => DisplayName;
}
}
+192
View File
@@ -0,0 +1,192 @@
namespace AutoBidder.Models
{
/// <summary>
/// Record per le statistiche aggregate di un prodotto nel database
/// </summary>
public class ProductStatisticsRecord
{
public string ProductKey { get; set; } = string.Empty;
public string ProductName { get; set; } = string.Empty;
// Contatori
public int TotalAuctions { get; set; }
public int WonAuctions { get; set; }
public int LostAuctions { get; set; }
// Statistiche prezzo
public double AvgFinalPrice { get; set; }
public double? MinFinalPrice { get; set; }
public double? MaxFinalPrice { get; set; }
public double? MedianFinalPrice { get; set; }
// Statistiche puntate
public double AvgBidsToWin { get; set; }
public int? MinBidsToWin { get; set; }
public int? MaxBidsToWin { get; set; }
// Statistiche reset
public double AvgResets { get; set; }
public int? MinResets { get; set; }
public int? MaxResets { get; set; }
// Limiti consigliati (calcolati dall'algoritmo)
public double? RecommendedMinPrice { get; set; }
public double? RecommendedMaxPrice { get; set; }
public int? RecommendedMinResets { get; set; }
public int? RecommendedMaxResets { get; set; }
public int? RecommendedMaxBids { get; set; }
// Valori di default definiti dall'utente (editabili)
public double? UserDefaultMinPrice { get; set; }
public double? UserDefaultMaxPrice { get; set; }
public int? UserDefaultMinResets { get; set; }
public int? UserDefaultMaxResets { get; set; }
public int? UserDefaultMaxBids { get; set; }
public int? UserDefaultBidBeforeDeadlineMs { get; set; }
/// <summary>
/// Se true, usa i limiti personalizzati del prodotto. Se false, usa i globali.
/// </summary>
public bool UseCustomLimits { get; set; }
// JSON con statistiche per fascia oraria
public string? HourlyStatsJson { get; set; }
// Metadata
public string? LastUpdated { get; set; }
/// <summary>
/// Calcola il win rate come percentuale
/// </summary>
public double WinRate => TotalAuctions > 0 ? (double)WonAuctions / TotalAuctions * 100 : 0;
}
/// <summary>
/// Risultato asta esteso con tutti i campi per analytics
/// </summary>
public class AuctionResultExtended
{
public int Id { get; set; }
public string AuctionId { get; set; } = "";
public string AuctionName { get; set; } = "";
public double FinalPrice { get; set; }
public int BidsUsed { get; set; }
public bool Won { get; set; }
public string Timestamp { get; set; } = "";
public double? BuyNowPrice { get; set; }
public double? ShippingCost { get; set; }
public double? TotalCost { get; set; }
public double? Savings { get; set; }
// Campi estesi per analytics
public string? WinnerUsername { get; set; }
public int? ClosedAtHour { get; set; }
public string? ProductKey { get; set; }
public int? TotalResets { get; set; }
public int? WinnerBidsUsed { get; set; }
}
/// <summary>
/// Limiti consigliati per un'asta basati sulle statistiche storiche
/// </summary>
public class RecommendedLimits
{
public double MinPrice { get; set; }
public double MaxPrice { get; set; }
public int MinResets { get; set; }
public int MaxResets { get; set; }
public int MaxBids { get; set; }
/// <summary>
/// Confidence score (0-100) - quanto sono affidabili questi limiti
/// </summary>
public int ConfidenceScore { get; set; }
/// <summary>
/// Numero di aste usate per calcolare i limiti
/// </summary>
public int SampleSize { get; set; }
/// <summary>
/// Fascia oraria migliore per vincere (0-23)
/// </summary>
public int? BestHourToPlay { get; set; }
/// <summary>
/// Win rate medio per questo prodotto
/// </summary>
public double? AverageWinRate { get; set; }
}
/// <summary>
/// Statistiche per fascia oraria
/// </summary>
public class HourlyStats
{
public int Hour { get; set; }
public int TotalAuctions { get; set; }
public int WonAuctions { get; set; }
public double AvgFinalPrice { get; set; }
public double AvgBidsUsed { get; set; }
public double WinRate => TotalAuctions > 0 ? (double)WonAuctions / TotalAuctions * 100 : 0;
}
/// <summary>
/// Record completo storia asta con tutte le metriche avanzate
/// </summary>
public class CompleteAuctionHistoryRecord
{
public int Id { get; set; }
public string AuctionId { get; set; } = "";
public string AuctionName { get; set; } = "";
public string? ProductKey { get; set; }
public string? OriginalUrl { get; set; }
// Dati finali
public double FinalPrice { get; set; }
public double? BuyNowPrice { get; set; }
public double? ShippingCost { get; set; }
public double? TotalCost { get; set; }
public double? Savings { get; set; }
public double? SavingsPercentage { get; set; }
// Risultato
public bool Won { get; set; }
public string? WinnerUsername { get; set; }
public int? WinnerBidsUsed { get; set; }
// Metriche competizione
public int TotalResets { get; set; }
public int TotalUniqueBidders { get; set; }
public int MaxHeatMetric { get; set; }
public double AvgHeatMetric { get; set; }
public int TotalCollisions { get; set; }
// Mie statistiche
public int MyBidsUsed { get; set; }
public int MySuccessfulBids { get; set; }
public int MyFailedBids { get; set; }
public int MyTimerExpired { get; set; }
public double? MyAvgLatencyMs { get; set; }
// Timestamps
public DateTime ClosedAt { get; set; }
public int ClosedAtHour { get; set; }
public int? DurationSeconds { get; set; }
public bool IsCompleteTracking { get; set; }
// JSON
public string? AggressiveBiddersJson { get; set; }
public string? BiddersSummaryJson { get; set; }
// Proprietà calcolate
public string DurationFormatted => DurationSeconds.HasValue
? TimeSpan.FromSeconds(DurationSeconds.Value).ToString(@"hh\:mm\:ss")
: "-";
public double SuccessRate => (MySuccessfulBids + MyFailedBids) > 0
? (double)MySuccessfulBids / (MySuccessfulBids + MyFailedBids) * 100
: 0;
}
}
+206
View File
@@ -0,0 +1,206 @@
@page
@model AutoBidder.Pages.Account.LoginModel
@{
Layout = null;
}
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Login - AutoBidder</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet" />
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
.login-card {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 20px;
padding: 40px;
width: 100%;
max-width: 380px;
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.3);
}
.login-header {
text-align: center;
margin-bottom: 35px;
}
.login-header h1 {
color: #fff;
font-size: 28px;
font-weight: 600;
margin-bottom: 8px;
}
.login-header p {
color: rgba(255, 255, 255, 0.6);
font-size: 14px;
}
.form-floating {
margin-bottom: 20px;
}
.form-floating .form-control {
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 12px;
color: #fff;
height: 55px;
padding: 16px;
}
.form-floating .form-control:focus {
background: rgba(255, 255, 255, 0.12);
border-color: #4f46e5;
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.25);
color: #fff;
}
.form-floating .form-control::placeholder {
color: transparent;
}
.form-floating label {
color: rgba(255, 255, 255, 0.5);
padding: 16px;
}
.form-floating .form-control:focus ~ label,
.form-floating .form-control:not(:placeholder-shown) ~ label {
color: rgba(255, 255, 255, 0.7);
}
.form-check {
margin-bottom: 25px;
}
.form-check-input {
background-color: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.3);
}
.form-check-input:checked {
background-color: #4f46e5;
border-color: #4f46e5;
}
.form-check-label {
color: rgba(255, 255, 255, 0.7);
font-size: 14px;
}
.btn-login {
width: 100%;
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
border: none;
border-radius: 12px;
color: #fff;
font-weight: 600;
font-size: 16px;
padding: 14px;
transition: all 0.3s ease;
}
.btn-login:hover {
transform: translateY(-2px);
box-shadow: 0 10px 30px rgba(79, 70, 229, 0.4);
color: #fff;
}
.btn-login:active {
transform: translateY(0);
}
.alert-error {
background: rgba(239, 68, 68, 0.15);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 12px;
color: #fca5a5;
padding: 12px 16px;
margin-bottom: 20px;
font-size: 14px;
}
.login-footer {
text-align: center;
margin-top: 25px;
padding-top: 20px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.login-footer small {
color: rgba(255, 255, 255, 0.4);
font-size: 12px;
}
.login-footer i {
margin-right: 5px;
}
</style>
</head>
<body>
<div class="login-card">
<div class="login-header">
<h1>AutoBidder</h1>
<p>Sistema Gestione Aste Bidoo</p>
</div>
@if (!string.IsNullOrEmpty(Model.ErrorMessage))
{
<div class="alert-error">
<i class="bi bi-exclamation-circle"></i>
@Model.ErrorMessage
</div>
}
<form method="post">
@Html.AntiForgeryToken()
<div class="form-floating">
<input type="text" class="form-control" id="username" name="Username"
placeholder="Username" value="@Model.Username" required autocomplete="username" />
<label for="username"><i class="bi bi-person"></i> Username</label>
</div>
<div class="form-floating">
<input type="password" class="form-control" id="password" name="Password"
placeholder="Password" required autocomplete="current-password" />
<label for="password"><i class="bi bi-lock"></i> Password</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="rememberMe" name="RememberMe" value="true" />
<label class="form-check-label" for="rememberMe">Ricordami</label>
</div>
<button type="submit" class="btn btn-login">
<i class="bi bi-box-arrow-in-right"></i> Accedi
</button>
</form>
<div class="login-footer">
<small><i class="bi bi-shield-lock"></i> Connessione sicura</small>
</div>
</div>
</body>
</html>
+89
View File
@@ -0,0 +1,89 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using AutoBidder.Models;
namespace AutoBidder.Pages.Account;
public class LoginModel : PageModel
{
private readonly SignInManager<ApplicationUser> _signInManager;
private readonly UserManager<ApplicationUser> _userManager;
public LoginModel(SignInManager<ApplicationUser> signInManager, UserManager<ApplicationUser> userManager)
{
_signInManager = signInManager;
_userManager = userManager;
}
[BindProperty]
public string Username { get; set; } = string.Empty;
[BindProperty]
public string Password { get; set; } = string.Empty;
[BindProperty]
public bool RememberMe { get; set; }
public string? ErrorMessage { get; set; }
[FromQuery(Name = "returnUrl")]
public string? ReturnUrl { get; set; }
public async Task<IActionResult> OnGetAsync()
{
// Se già autenticato, vai alla home
if (User.Identity?.IsAuthenticated == true)
{
return LocalRedirect(GetSafeReturnUrl());
}
// Logout eventuali sessioni precedenti
await HttpContext.SignOutAsync(IdentityConstants.ApplicationScheme);
return Page();
}
public async Task<IActionResult> OnPostAsync()
{
if (string.IsNullOrEmpty(Username) || string.IsNullOrEmpty(Password))
{
ErrorMessage = "Inserisci username e password.";
return Page();
}
var result = await _signInManager.PasswordSignInAsync(
Username,
Password,
RememberMe,
lockoutOnFailure: true
);
if (result.Succeeded)
{
return LocalRedirect(GetSafeReturnUrl());
}
if (result.IsLockedOut)
{
ErrorMessage = "Account bloccato. Riprova tra qualche minuto.";
}
else
{
ErrorMessage = "Username o password non validi.";
}
return Page();
}
private string GetSafeReturnUrl()
{
// Ritorna solo URL locali sicuri
if (!string.IsNullOrEmpty(ReturnUrl) && Url.IsLocalUrl(ReturnUrl))
{
return ReturnUrl;
}
return "/";
}
}
+5
View File
@@ -0,0 +1,5 @@
@page
@model AutoBidder.Pages.Account.LogoutModel
@{
Layout = null;
}
+21
View File
@@ -0,0 +1,21 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace AutoBidder.Pages.Account;
public class LogoutModel : PageModel
{
public async Task<IActionResult> OnGetAsync()
{
await HttpContext.SignOutAsync(IdentityConstants.ApplicationScheme);
return Redirect("/Account/Login");
}
public async Task<IActionResult> OnPostAsync()
{
await HttpContext.SignOutAsync(IdentityConstants.ApplicationScheme);
return Redirect("/Account/Login");
}
}
+671
View File
@@ -0,0 +1,671 @@
@page "/browser"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@using AutoBidder.Models
@using AutoBidder.Services
@inject BidooBrowserService BrowserService
@inject ApplicationStateService AppState
@inject AuctionMonitor AuctionMonitor
@inject IJSRuntime JSRuntime
@implements IDisposable
<PageTitle>Esplora Aste - AutoBidder</PageTitle>
<div class="browser-container animate-fade-in p-4">
<!-- Header -->
<div class="d-flex align-items-center justify-content-between mb-4 flex-wrap gap-3">
<div class="d-flex align-items-center animate-fade-in-down">
<i class="bi bi-search text-primary me-3" style="font-size: 2rem;"></i>
<div>
<h2 class="mb-0 fw-bold">Esplora Aste</h2>
<small class="text-muted">Naviga le aste pubbliche di Bidoo senza login</small>
</div>
</div>
<div class="d-flex gap-2 flex-wrap">
<button class="btn btn-outline-secondary" @onclick="RefreshAll" disabled="@isLoading">
<i class="bi @(isLoading ? "bi-arrow-clockwise spin" : "bi-arrow-clockwise")"></i>
Aggiorna
</button>
@if (auctions.Count > 0)
{
<button class="btn btn-outline-danger" @onclick="ClearAllAuctions" title="Rimuove tutte le aste caricate e ferma il polling">
<i class="bi bi-trash"></i>
Pulisci Tutto
</button>
}
</div>
</div>
<!-- Category Selector -->
<div class="card mb-4 shadow-sm">
<div class="card-body">
<div class="row g-3 align-items-end">
<div class="col-md-6">
<label class="form-label fw-semibold">
<i class="bi bi-tag me-2"></i>Categoria
</label>
<select class="form-select form-select-lg" @bind="selectedCategoryIndex" @bind:after="OnCategoryChanged">
@if (categories.Count == 0)
{
<option value="-1">Caricamento categorie...</option>
}
else
{
@for (int i = 0; i < categories.Count; i++)
{
<option value="@i">
@if (!string.IsNullOrEmpty(categories[i].Icon))
{
@categories[i].DisplayName
}
else
{
@categories[i].DisplayName
}
</option>
}
}
</select>
</div>
<div class="col-md-3">
<div class="stats-mini">
<span class="text-muted">Aste caricate:</span>
<span class="fw-bold text-primary ms-2">@auctions.Count</span>
</div>
</div>
<div class="col-md-3">
<div class="stats-mini">
<span class="text-muted">Monitorate:</span>
<span class="fw-bold text-success ms-2">@auctions.Count(a => a.IsMonitored)</span>
</div>
</div>
</div>
</div>
</div>
<!-- ? NUOVO: Search Bar -->
<div class="card mb-4 shadow-sm">
<div class="card-body">
<div class="row g-3 align-items-center">
<div class="col-md-8">
<div class="input-group input-group-lg">
<span class="input-group-text bg-primary text-white border-0">
<i class="bi bi-search"></i>
</span>
<input type="text"
class="form-control form-control-lg border-0"
placeholder="Cerca per nome asta, prezzo, vincitore..."
@bind="searchQuery"
@bind:event="oninput"
@bind:after="OnSearchChanged" />
@if (!string.IsNullOrEmpty(searchQuery))
{
<button class="btn btn-outline-secondary border-0"
@onclick="ClearSearch"
title="Cancella ricerca">
<i class="bi bi-x-lg"></i>
</button>
}
</div>
</div>
<div class="col-md-4">
<div class="stats-mini">
<span class="text-muted">Risultati filtrati:</span>
<span class="fw-bold text-info ms-2">@filteredAuctions.Count</span>
</div>
</div>
</div>
</div>
</div>
<!-- Loading State -->
@if (isLoading)
{
<div class="text-center py-5 animate-fade-in">
<div class="spinner-border text-primary mb-3" role="status" style="width: 3rem; height: 3rem;">
<span class="visually-hidden">Caricamento...</span>
</div>
<p class="text-muted">Caricamento aste in corso...</p>
</div>
}
else if (errorMessage != null)
{
<div class="alert alert-warning d-flex align-items-center animate-scale-in">
<i class="bi bi-exclamation-triangle-fill me-3" style="font-size: 1.5rem;"></i>
<div>
<strong>Attenzione</strong><br />
@errorMessage
</div>
</div>
}
else if (filteredAuctions.Count == 0 && !string.IsNullOrEmpty(searchQuery))
{
<div class="text-center py-5 animate-fade-in">
<i class="bi bi-search text-muted" style="font-size: 4rem;"></i>
<p class="text-muted mt-3">Nessuna asta trovata per "<strong>@searchQuery</strong>"</p>
<button class="btn btn-outline-secondary" @onclick="ClearSearch">
<i class="bi bi-x-circle me-2"></i>Cancella Ricerca
</button>
</div>
}
else if (auctions.Count == 0)
{
<div class="text-center py-5 animate-fade-in">
<i class="bi bi-inbox text-muted" style="font-size: 4rem;"></i>
<p class="text-muted mt-3">Nessuna asta trovata in questa categoria</p>
<button class="btn btn-primary" @onclick="LoadAuctions">
<i class="bi bi-arrow-clockwise me-2"></i>Ricarica
</button>
</div>
}
else
{
<!-- Auctions Grid -->
<div class="auction-grid animate-fade-in">
@foreach (var auction in filteredAuctions)
{
<div class="auction-card @(auction.IsMonitored ? "monitored" : "") @(auction.IsSold ? "sold" : "")">
<!-- Image -->
<div class="auction-image">
@if (!string.IsNullOrEmpty(auction.ImageUrl))
{
<img src="@auction.ImageUrl" alt="@auction.Name" loading="lazy" />
}
else
{
<div class="placeholder-image">
<i class="bi bi-image"></i>
</div>
}
<!-- Badges -->
<div class="auction-badges">
@if (auction.IsCreditAuction)
{
<span class="badge bg-warning text-dark">
<i class="bi bi-coin"></i> @auction.CreditValue
</span>
}
@if (auction.IsManualOnly)
{
<span class="badge bg-info">
<i class="bi bi-hand-index"></i> Manuale
</span>
}
@if (auction.IsTurbo)
{
<span class="badge bg-danger">
<i class="bi bi-lightning"></i> @auction.TimerFrequency s
</span>
}
</div>
@if (auction.IsSold)
{
<div class="sold-overlay">
<span>VENDUTO</span>
</div>
}
@if (auction.IsMonitored)
{
<div class="monitored-badge">
<i class="bi bi-check-circle-fill"></i>
</div>
}
</div>
<!-- Info -->
<div class="auction-info">
<h6 class="auction-name" title="@auction.Name">@auction.Name</h6>
<div class="auction-price">
<span class="current-price">@auction.CurrentPrice.ToString("0.00") €</span>
@if (auction.BuyNowPrice > 0)
{
<span class="buynow-price text-muted">
<small>Compra: @auction.BuyNowPrice.ToString("0.00") €</small>
</span>
}
</div>
<div class="auction-bidder">
<i class="bi bi-person-fill text-muted me-1"></i>
<span>@(string.IsNullOrEmpty(auction.LastBidder) ? "—" : auction.LastBidder)</span>
</div>
<div class="auction-timer @(auction.RemainingSeconds <= 3 ? "urgent" : "")">
<i class="bi bi-clock me-1"></i>
@auction.TimerDisplay
</div>
</div>
<!-- Actions -->
<div class="auction-actions">
<div class="d-flex gap-1 mb-1">
<button class="btn btn-outline-secondary btn-sm flex-grow-1"
@onclick="() => CopyAuctionLink(auction)"
title="Copia link">
<i class="bi bi-clipboard"></i>
</button>
<button class="btn btn-outline-secondary btn-sm flex-grow-1"
@onclick="() => OpenAuctionInNewTab(auction)"
title="Apri in nuova scheda">
<i class="bi bi-box-arrow-up-right"></i>
</button>
</div>
@if (auction.IsMonitored)
{
<button class="btn btn-warning btn-sm w-100" @onclick="() => RemoveFromMonitor(auction)">
<i class="bi bi-dash-lg me-1"></i>Togli dal Monitor
</button>
}
else
{
<button class="btn btn-primary btn-sm w-100" @onclick="() => AddToMonitor(auction)">
<i class="bi bi-plus-lg me-1"></i>Aggiungi al Monitor
</button>
}
</div>
</div>
}
</div>
<!-- Load More -->
@if (canLoadMore)
{
<div class="text-center mt-4">
<button class="btn btn-outline-primary btn-lg" @onclick="LoadMoreAuctions" disabled="@isLoadingMore">
@if (isLoadingMore)
{
<span class="spinner-border spinner-border-sm me-2"></span>
}
else
{
<i class="bi bi-plus-circle me-2"></i>
}
Carica Altre Aste
</button>
</div>
}
}
</div>
@code {
private List<BidooCategoryInfo> categories = new();
private List<BidooBrowserAuction> auctions = new();
private List<BidooBrowserAuction> filteredAuctions = new();
// 🔥 Usa stato persistente da AppState
private int selectedCategoryIndex
{
get => AppState.BrowserCategoryIndex;
set => AppState.BrowserCategoryIndex = value;
}
private int currentPage = 0;
private bool isLoading = false;
private bool isLoadingMore = false;
private bool canLoadMore = true;
private string? errorMessage = null;
// 🔥 Usa stato persistente per la ricerca
private string searchQuery
{
get => AppState.BrowserSearchQuery;
set => AppState.BrowserSearchQuery = value;
}
private System.Threading.Timer? stateUpdateTimer;
private CancellationTokenSource? cts;
private bool isUpdatingInBackground = false;
protected override async Task OnInitializedAsync()
{
await LoadCategories();
// 🔥 Se c'è una categoria salvata, carica le aste
if (categories.Count > 0)
{
// Se selectedCategoryIndex è valido, carica quella categoria
if (selectedCategoryIndex >= 0 && selectedCategoryIndex < categories.Count)
{
await LoadAuctions();
}
else
{
// Altrimenti carica la prima categoria
selectedCategoryIndex = 0;
await LoadAuctions();
}
}
// Auto-update states every 500ms for real-time price updates
stateUpdateTimer = new System.Threading.Timer(async _ =>
{
if (auctions.Count > 0 && !isUpdatingInBackground)
{
await UpdateAuctionStatesBackground();
}
}, null, TimeSpan.FromMilliseconds(500), TimeSpan.FromMilliseconds(500));
}
private async Task LoadCategories()
{
try
{
categories = await BrowserService.GetCategoriesAsync();
}
catch (Exception ex)
{
Console.WriteLine($"[Browser] Error loading categories: {ex.Message}");
errorMessage = "Errore nel caricamento delle categorie";
}
}
private async Task OnCategoryChanged()
{
currentPage = 0;
canLoadMore = true;
auctions.Clear();
await LoadAuctions();
}
private async Task LoadAuctions()
{
if (categories.Count == 0 || selectedCategoryIndex < 0 || selectedCategoryIndex >= categories.Count)
return;
isLoading = true;
errorMessage = null;
cts?.Cancel();
cts = new CancellationTokenSource();
try
{
var category = categories[selectedCategoryIndex];
var newAuctions = await BrowserService.GetAuctionsAsync(category, currentPage, cts.Token);
auctions = newAuctions;
canLoadMore = newAuctions.Count >= 20; // Assume pagination at 20
// Mark already monitored auctions
UpdateMonitoredStatus();
// Get initial states
if (auctions.Count > 0)
{
await BrowserService.UpdateAuctionStatesAsync(auctions, cts.Token);
}
// ? NUOVO: Applica filtro ricerca
ApplySearchFilter();
}
catch (OperationCanceledException)
{
// Ignore cancellation
}
catch (Exception ex)
{
Console.WriteLine($"[Browser] Error loading auctions: {ex.Message}");
errorMessage = "Errore nel caricamento delle aste";
}
finally
{
isLoading = false;
StateHasChanged();
}
}
// ? NUOVO: Metodo per applicare il filtro di ricerca
private void ApplySearchFilter()
{
if (string.IsNullOrWhiteSpace(searchQuery))
{
filteredAuctions = auctions.ToList();
return;
}
var query = searchQuery.ToLowerInvariant().Trim();
filteredAuctions = auctions.Where(a =>
// Cerca nel nome
a.Name.ToLowerInvariant().Contains(query) ||
// Cerca nel prezzo corrente
a.CurrentPrice.ToString("F2").Contains(query) ||
// Cerca nel prezzo buy-now
(a.BuyNowPrice > 0 && a.BuyNowPrice.ToString("F2").Contains(query)) ||
// Cerca nel nome dell'ultimo puntatore
(!string.IsNullOrEmpty(a.LastBidder) && a.LastBidder.ToLowerInvariant().Contains(query)) ||
// Cerca nell'ID asta
a.AuctionId.Contains(query)
).ToList();
}
// ? NUOVO: Callback quando cambia la ricerca
private void OnSearchChanged()
{
ApplySearchFilter();
StateHasChanged();
}
// ? NUOVO: Pulisce la ricerca
private void ClearSearch()
{
searchQuery = "";
ApplySearchFilter();
StateHasChanged();
}
private async Task LoadMoreAuctions()
{
if (categories.Count == 0 || selectedCategoryIndex < 0 || auctions.Count == 0)
return;
isLoadingMore = true;
cts?.Cancel();
cts = new CancellationTokenSource();
try
{
var category = categories[selectedCategoryIndex];
var existingIds = auctions.Select(a => a.AuctionId).ToList();
// Usa GetMoreAuctionsAsync che evita duplicati
var newAuctions = await BrowserService.GetMoreAuctionsAsync(category, existingIds, cts.Token);
if (newAuctions.Count == 0)
{
canLoadMore = false;
}
else
{
auctions.AddRange(newAuctions);
UpdateMonitoredStatus();
// Aggiorna stati delle nuove aste
await BrowserService.UpdateAuctionStatesAsync(newAuctions, cts.Token);
// ? NUOVO: Riapplica filtro dopo caricamento
ApplySearchFilter();
}
}
catch (Exception ex)
{
Console.WriteLine($"[Browser] Error loading more auctions: {ex.Message}");
}
finally
{
isLoadingMore = false;
StateHasChanged();
}
}
private async Task UpdateAuctionStatesBackground()
{
if (isUpdatingInBackground) return;
isUpdatingInBackground = true;
try
{
await BrowserService.UpdateAuctionStatesAsync(auctions);
UpdateMonitoredStatus();
await InvokeAsync(StateHasChanged);
}
catch
{
// Ignore background errors
}
finally
{
isUpdatingInBackground = false;
}
}
private async Task RefreshAll()
{
await LoadCategories();
currentPage = 0;
canLoadMore = true;
auctions.Clear();
await LoadAuctions();
}
private void ClearAllAuctions()
{
// Cancella le aste e ferma il timer
cts?.Cancel();
auctions.Clear();
filteredAuctions.Clear();
StateHasChanged();
}
private void UpdateMonitoredStatus()
{
var monitoredIds = AppState.Auctions.Select(a => a.AuctionId).ToHashSet();
foreach (var auction in auctions)
{
auction.IsMonitored = monitoredIds.Contains(auction.AuctionId);
}
}
private void AddToMonitor(BidooBrowserAuction browserAuction)
{
if (browserAuction.IsMonitored) return;
// 🔥 Carica impostazioni di default
var settings = AutoBidder.Utilities.SettingsManager.Load();
// 🔥 Determina stato iniziale da impostazioni
bool isActive = false;
bool isPaused = false;
switch (settings.DefaultNewAuctionState)
{
case "Active":
isActive = true;
isPaused = false;
break;
case "Paused":
isActive = true;
isPaused = true;
break;
case "Stopped":
default:
isActive = false;
isPaused = false;
break;
}
var auctionInfo = new AuctionInfo
{
AuctionId = browserAuction.AuctionId,
Name = browserAuction.Name,
OriginalUrl = browserAuction.Url,
BuyNowPrice = (double)browserAuction.BuyNowPrice,
// 🔥 Applica valori dalle impostazioni
BidBeforeDeadlineMs = settings.DefaultBidBeforeDeadlineMs,
CheckAuctionOpenBeforeBid = settings.DefaultCheckAuctionOpenBeforeBid,
MinPrice = settings.DefaultMinPrice,
MaxPrice = settings.DefaultMaxPrice,
MinResets = settings.DefaultMinResets,
MaxResets = settings.DefaultMaxResets,
// 🔥 Usa stato da impostazioni invece di hardcoded
IsActive = isActive,
IsPaused = isPaused,
AddedAt = DateTime.UtcNow
};
AppState.AddAuction(auctionInfo);
// ?? FIX CRITICO: Registra l'asta nel monitor!
AuctionMonitor.AddAuction(auctionInfo);
browserAuction.IsMonitored = true;
// Save to disk
AutoBidder.Utilities.PersistenceManager.SaveAuctions(AppState.Auctions.ToList());
// ?? FIX CRITICO: Avvia il monitor se non è già attivo
if (!AppState.IsMonitoringActive)
{
AuctionMonitor.Start();
AppState.IsMonitoringActive = true;
Console.WriteLine($"[AuctionBrowser] Monitor auto-started for paused auction: {auctionInfo.Name}");
}
StateHasChanged();
}
private void RemoveFromMonitor(BidooBrowserAuction browserAuction)
{
if (!browserAuction.IsMonitored) return;
// Trova l'asta nel monitor
var auctionToRemove = AppState.Auctions.FirstOrDefault(a => a.AuctionId == browserAuction.AuctionId);
if (auctionToRemove != null)
{
AppState.RemoveAuction(auctionToRemove);
browserAuction.IsMonitored = false;
// Save to disk
AutoBidder.Utilities.PersistenceManager.SaveAuctions(AppState.Auctions.ToList());
}
StateHasChanged();
}
private async Task CopyAuctionLink(BidooBrowserAuction auction)
{
try
{
await JSRuntime.InvokeVoidAsync("navigator.clipboard.writeText", auction.Url);
}
catch (Exception ex)
{
Console.WriteLine($"[Browser] Error copying link: {ex.Message}");
}
}
private async Task OpenAuctionInNewTab(BidooBrowserAuction auction)
{
try
{
await JSRuntime.InvokeVoidAsync("window.open", auction.Url, "_blank");
}
catch (Exception ex)
{
Console.WriteLine($"[Browser] Error opening link: {ex.Message}");
}
}
public void Dispose()
{
stateUpdateTimer?.Dispose();
cts?.Cancel();
cts?.Dispose();
}
}
-32
View File
@@ -1,32 +0,0 @@
@page "/freebids"
<PageTitle>Puntate Gratuite - AutoBidder</PageTitle>
<div class="freebids-container animate-fade-in p-4">
<div class="d-flex align-items-center mb-4 animate-fade-in-down">
<i class="bi bi-gift-fill text-warning me-3" style="font-size: 2.5rem;"></i>
<h2 class="mb-0 fw-bold">Puntate Gratuite</h2>
</div>
<!-- Feature Under Development Notice - Conciso -->
<div class="alert alert-info border-0 shadow-sm animate-scale-in">
<div class="d-flex align-items-center">
<i class="bi bi-tools me-3" style="font-size: 2rem;"></i>
<div class="flex-grow-1">
<h5 class="mb-2"><strong>Funzionalità in Sviluppo</strong></h5>
<p class="mb-0">
Sistema di rilevamento, raccolta e utilizzo automatico delle puntate gratuite di Bidoo.
<br />
<small class="text-muted">Disponibile in una prossima versione con statistiche dettagliate.</small>
</p>
</div>
</div>
</div>
</div>
<style>
.freebids-container {
max-width: 1200px;
margin: 0 auto;
}
</style>
+1
View File
@@ -1,4 +1,5 @@
@page "/health" @page "/health"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@inject DatabaseService DatabaseService @inject DatabaseService DatabaseService
@inject AuctionMonitor AuctionMonitor @inject AuctionMonitor AuctionMonitor
+338 -241
View File
@@ -1,4 +1,5 @@
@page "/" @page "/"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@inject AuctionMonitor AuctionMonitor @inject AuctionMonitor AuctionMonitor
@inject AuctionStateService AuctionStateService @inject AuctionStateService AuctionStateService
@inject IJSRuntime JSRuntime @inject IJSRuntime JSRuntime
@@ -6,142 +7,155 @@
<PageTitle>Monitor Aste - AutoBidder</PageTitle> <PageTitle>Monitor Aste - AutoBidder</PageTitle>
<div class="auction-monitor animate-fade-in"> <div class="auction-monitor-container">
<!-- Toolbar Superiore --> <!-- Toolbar Compatta -->
<div class="toolbar animate-fade-in-down"> <div class="toolbar-compact">
<!-- Box Sessione Utente - Compatto in linea --> <!-- Pulsanti Azioni Massiva (senza conteggi) -->
<div class="toolbar-user-info"> <div class="btn-group-actions">
@if (!string.IsNullOrEmpty(sessionUsername)) <button class="action-btn success" @onclick="StartAll" title="Avvia tutte le aste">
{ <i class="bi bi-play-fill"></i>
<div class="user-card connected"> </button>
<i class="bi bi-person-circle user-icon"></i> <button class="action-btn warning" @onclick="PauseAll" title="Metti in pausa tutte le aste">
<span class="user-name">@sessionUsername</span> <i class="bi bi-pause-fill"></i>
<div class="divider"></div> </button>
<div class="stat-compact"> <button class="action-btn secondary" @onclick="StopAll" title="Ferma tutte le aste">
<i class="bi bi-hand-index-thumb-fill"></i> <i class="bi bi-stop-fill"></i>
<span class="stat-value @GetBidsClass()">@sessionRemainingBids</span> </button>
</div> </div>
<div class="divider"></div>
<div class="stat-compact"> <!-- Indicatori Stato Aste (tutti gli stati) -->
<i class="bi bi-wallet2"></i> <div class="status-indicators">
<span class="stat-value text-success">€@sessionShopCredit.ToString("F2")</span> <div class="status-pill total" title="Totale aste">
<i class="bi bi-collection"></i>
<span>@(auctions?.Count ?? 0)</span>
</div> </div>
@if (sessionAuctionsWon > 0) <div class="status-pill active" title="Aste attive">
{ <i class="bi bi-play-circle-fill"></i>
<div class="divider"></div> <span>@GetActiveAuctionsCount()</span>
<div class="stat-compact"> </div>
<div class="status-pill paused" title="Aste in pausa">
<i class="bi bi-pause-circle-fill"></i>
<span>@GetPausedAuctionsCount()</span>
</div>
<div class="status-pill stopped" title="Aste fermate">
<i class="bi bi-stop-circle-fill"></i>
<span>@GetStoppedAuctionsCount()</span>
</div>
<div class="status-pill won" title="Aste vinte">
<i class="bi bi-trophy-fill"></i> <i class="bi bi-trophy-fill"></i>
<span class="stat-value text-warning">@sessionAuctionsWon</span> <span>@GetWonAuctionsCount()</span>
</div> </div>
} <div class="status-pill lost" title="Aste perse">
<i class="bi bi-x-circle-fill"></i>
<span>@GetLostAuctionsCount()</span>
</div> </div>
}
else
{
<div class="user-card disconnected">
<i class="bi bi-person-x user-icon"></i>
<span class="user-name text-muted">Non connesso</span>
</div>
}
</div> </div>
<!-- Pulsanti Azioni (Centro-Destra) --> <!-- Pulsanti Gestione -->
<div class="toolbar-actions"> <div class="btn-group-manage">
<button class="btn btn-success hover-lift" @onclick="StartAll" disabled="@isMonitoringActive"> <button class="manage-btn primary" @onclick="ShowAddAuctionDialog" title="Aggiungi nuova asta">
<i class="bi bi-play-fill"></i> Avvia Tutto <i class="bi bi-plus-lg"></i>
</button> </button>
<button class="btn btn-warning hover-lift" @onclick="PauseAll" disabled="@(!isMonitoringActive)"> <button class="manage-btn danger" @onclick="RemoveSelectedAuction" disabled="@(selectedAuction == null)" title="Rimuovi selezionata">
<i class="bi bi-pause-fill"></i> Pausa Tutto <i class="bi bi-trash"></i>
</button> </button>
<button class="btn btn-danger hover-lift" @onclick="StopAll" disabled="@(!isMonitoringActive)"> <div class="manage-separator"></div>
<i class="bi bi-stop-fill"></i> Ferma Tutto <button class="manage-btn outline-success" @onclick="RemoveActiveAuctions" disabled="@(GetActiveAuctionsCount() == 0)" title="Rimuovi attive">
<i class="bi bi-play-circle"></i>
</button> </button>
<button class="btn btn-primary ms-3 hover-lift" @onclick="ShowAddAuctionDialog"> <button class="manage-btn outline-warning" @onclick="RemovePausedAuctions" disabled="@(GetPausedAuctionsCount() == 0)" title="Rimuovi in pausa">
<i class="bi bi-plus-lg"></i> Aggiungi Asta <i class="bi bi-pause-circle"></i>
</button> </button>
<button class="btn btn-secondary hover-lift" @onclick="RemoveSelectedAuction" disabled="@(selectedAuction == null)"> <button class="manage-btn outline-secondary" @onclick="RemoveStoppedAuctions" disabled="@(GetStoppedAuctionsCount() == 0)" title="Rimuovi fermate">
<i class="bi bi-trash"></i> Rimuovi <i class="bi bi-stop-circle"></i>
</button>
<button class="manage-btn outline-gold" @onclick="RemoveWonAuctions" disabled="@(GetWonAuctionsCount() == 0)" title="Rimuovi vinte">
<i class="bi bi-trophy"></i>
</button>
<button class="manage-btn outline-danger" @onclick="RemoveLostAuctions" disabled="@(GetLostAuctionsCount() == 0)" title="Rimuovi perse">
<i class="bi bi-x-circle"></i>
</button>
<div class="manage-separator"></div>
<button class="manage-btn danger-fill" @onclick="RemoveAllAuctions" disabled="@((auctions?.Count ?? 0) == 0)" title="Rimuovi TUTTE">
<i class="bi bi-trash-fill"></i>
</button> </button>
</div> </div>
</div> </div>
<div class="content-layout"> <!-- Area Principale con Layout a Griglia -->
<!-- GRIGLIA ASTE - PARTE SUPERIORE SINISTRA --> <div class="main-content-area">
<div class="auctions-grid-section animate-fade-in-left delay-100 shadow-hover"> <!-- Riga Superiore: Aste + Log -->
<h3><i class="bi bi-list-check"></i> Aste Monitorate (@auctions.Count)</h3> <div class="top-row" id="topRow">
@if (auctions.Count == 0) <!-- Pannello Aste -->
<div class="panel panel-auctions" id="panelAuctions">
<div class="panel-header">
<span><i class="bi bi-list-check"></i> Aste Monitorate</span>
</div>
@if ((auctions?.Count ?? 0) == 0)
{ {
<div class="alert alert-info animate-fade-in-up"> <div class="alert alert-info animate-fade-in-up m-2">
<i class="bi bi-info-circle"></i> Nessuna asta monitorata. Clicca su "Aggiungi Asta" per iniziare. <i class="bi bi-info-circle"></i> Nessuna asta monitorata. Clicca su <i class="bi bi-plus-lg"></i> per iniziare.
</div> </div>
} }
else else
{ {
<div class="table-responsive"> <div class="table-responsive panel-content">
<table class="table table-striped table-hover mb-0 table-fixed"> <table class="table table-striped table-hover mb-0 table-fixed table-compact">
<thead> <thead>
<tr> <tr>
<th class="col-stato"><i class="bi bi-toggle-on"></i> Stato</th> <th class="col-stato sortable-header" @onclick='() => SortAuctionsBy("stato")'>Stato @GetSortIndicator("stato")</th>
<th class="col-nome"><i class="bi bi-tag"></i> Nome</th> <th class="col-nome sortable-header" @onclick='() => SortAuctionsBy("nome")'>Nome @GetSortIndicator("nome")</th>
<th class="col-prezzo"><i class="bi bi-currency-euro"></i> Prezzo</th> <th class="col-prezzo sortable-header" @onclick='() => SortAuctionsBy("prezzo")'>€ @GetSortIndicator("prezzo")</th>
<th class="col-timer"><i class="bi bi-clock"></i> Timer</th> <th class="col-timer sortable-header" @onclick='() => SortAuctionsBy("timer")'>Timer @GetSortIndicator("timer")</th>
<th class="col-ultimo"><i class="bi bi-person"></i> Ultimo</th> <th class="col-ultimo">Ultimo</th>
<th class="col-click"><i class="bi bi-hand-index"></i> Click</th> <th class="col-click sortable-header" @onclick='() => SortAuctionsBy("puntate")'>Punt. @GetSortIndicator("puntate")</th>
<th class="col-ping"><i class="bi bi-speedometer"></i> Ping</th> <th class="col-ping sortable-header" @onclick='() => SortAuctionsBy("ping")'>Ping @GetSortIndicator("ping")</th>
<th class="col-azioni"><i class="bi bi-gear"></i> Azioni</th> <th class="col-azioni">Azioni</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@foreach (var auction in auctions) @foreach (var auction in GetSortedAuctions())
{ {
<tr class="@GetRowClass(auction) table-row-enter transition-all" <tr class="@GetRowClass(auction) @(selectedAuction == auction ? "selected-row" : "")"
@onclick="() => SelectAuction(auction)" @onclick="() => SelectAuction(auction)">
style="cursor: pointer;">
<td class="col-stato"> <td class="col-stato">
<span class="badge @GetStatusBadgeClass(auction) @GetStatusAnimationClass(auction)"> <span class="badge @GetStatusBadgeClass(auction) @GetStatusAnimationClass(auction)">
@((MarkupString)GetStatusIcon(auction)) @GetStatusText(auction) @((MarkupString)GetStatusIcon(auction)) @GetStatusText(auction)
</span> </span>
</td> </td>
<td class="col-nome fw-semibold">@auction.Name</td> <td class="col-nome">@auction.Name</td>
<td class="col-prezzo @GetPriceClass(auction)">@GetPriceDisplay(auction)</td> <td class="col-prezzo @GetPriceClass(auction)">@GetPriceDisplay(auction)</td>
<td class="col-timer">@GetTimerDisplay(auction)</td> <td class="col-timer">@GetTimerDisplay(auction)</td>
<td class="col-ultimo">@GetLastBidder(auction)</td> <td class="col-ultimo">@GetLastBidder(auction)</td>
<td class="col-click"><span class="badge bg-info">@GetMyBidsCount(auction)</span></td> <td class="col-click bids-column">@GetMyBidsCount(auction)</td>
<td class="col-ping">@GetPingDisplay(auction)</td> <td class="col-ping @GetPingClass(auction)">@GetPingDisplay(auction)</td>
<td class="col-azioni"> <td class="col-azioni">
<div class="btn-group btn-group-sm" @onclick:stopPropagation="true"> <div class="btn-group btn-group-sm" @onclick:stopPropagation="true">
<button class="btn btn-primary hover-scale" <button class="btn btn-xs btn-primary"
@onclick="() => ManualBidAuction(auction)" @onclick="() => ManualBidAuction(auction)"
title="Punta Manualmente" title="Punta"
disabled="@IsManualBidding(auction)"> disabled="@IsManualBidding(auction)">
@if (IsManualBidding(auction))
{
<span class="spinner-border spinner-border-sm" role="status"></span>
}
else
{
<i class="bi bi-hand-index-thumb"></i> <i class="bi bi-hand-index-thumb"></i>
}
</button> </button>
@if (auction.IsActive && !auction.IsPaused) @if (auction.IsActive && !auction.IsPaused)
{ {
<button class="btn btn-warning hover-scale" @onclick="() => PauseAuction(auction)" title="Pausa"> <button class="btn btn-xs btn-warning" @onclick="() => PauseAuction(auction)" title="Pausa">
<i class="bi bi-pause-fill"></i> <i class="bi bi-pause-fill"></i>
</button> </button>
} }
else if (auction.IsPaused) else if (auction.IsPaused)
{ {
<button class="btn btn-success hover-scale" @onclick="() => ResumeAuction(auction)" title="Riprendi"> <button class="btn btn-xs btn-success" @onclick="() => ResumeAuction(auction)" title="Riprendi">
<i class="bi bi-play-fill"></i> <i class="bi bi-play-fill"></i>
</button> </button>
} }
else else
{ {
<button class="btn btn-success hover-scale" @onclick="() => StartAuction(auction)" title="Avvia"> <button class="btn btn-xs btn-success" @onclick="() => StartAuction(auction)" title="Avvia">
<i class="bi bi-play-fill"></i> <i class="bi bi-play-fill"></i>
</button> </button>
} }
<button class="btn btn-danger hover-scale" @onclick="() => StopAuction(auction)" title="Ferma"> <button class="btn btn-xs btn-danger" @onclick="() => StopAuction(auction)" title="Ferma">
<i class="bi bi-stop-fill"></i> <i class="bi bi-stop-fill"></i>
</button> </button>
</div> </div>
@@ -154,18 +168,18 @@
} }
</div> </div>
<!-- SPLITTER VERTICALE --> <!-- Splitter Verticale -->
<div class="splitter-vertical"></div> <div class="gutter gutter-vertical" id="gutterVertical"></div>
<!-- LOG GLOBALE - PARTE SUPERIORE DESTRA --> <!-- Pannello Log -->
<div class="global-log animate-fade-in-right delay-200"> <div class="panel panel-log" id="panelLog">
<div class="d-flex justify-content-between align-items-center"> <div class="panel-header">
<h4 class="mb-0"><i class="bi bi-terminal"></i> Log Globale</h4> <span><i class="bi bi-terminal"></i> Log</span>
<button class="btn btn-sm btn-secondary" @onclick="ClearGlobalLog"> <button class="btn btn-xs btn-secondary" @onclick="ClearGlobalLog">
<i class="bi bi-trash"></i> <i class="bi bi-trash"></i>
</button> </button>
</div> </div>
<div class="log-box"> <div class="panel-content log-box" id="globalLogContainer" @ref="globalLogRef">
@if (globalLog.Count == 0) @if (globalLog.Count == 0)
{ {
<div class="text-muted"><i class="bi bi-inbox"></i> Nessun log ancora...</div> <div class="text-muted"><i class="bi bi-inbox"></i> Nessun log ancora...</div>
@@ -181,14 +195,19 @@
</div> </div>
</div> </div>
<!-- SPLITTER ORIZZONTALE --> <!-- Splitter Orizzontale -->
<div class="splitter-horizontal"></div> <div class="gutter gutter-horizontal" id="gutterHorizontal"></div>
<!-- DETTAGLI ASTA CON TABS - PARTE INFERIORE (full width) --> <!-- Riga Inferiore: Dettagli Asta -->
<div class="bottom-row" id="bottomRow">
<div class="panel panel-details" id="panelDetails">
@if (selectedAuction != null) @if (selectedAuction != null)
{ {
<div class="auction-details-tabs animate-fade-in-up delay-300 shadow-hover"> <div class="auction-details-content">
<h3><i class="bi bi-info-circle-fill"></i> @selectedAuction.Name <small class="text-muted">(ID: @selectedAuction.AuctionId)</small></h3> <div class="details-header">
<i class="bi bi-info-circle-fill"></i> @selectedAuction.Name
<small class="text-muted">(ID: @selectedAuction.AuctionId)</small>
</div>
<ul class="nav nav-tabs" role="tablist"> <ul class="nav nav-tabs" role="tablist">
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
@@ -224,52 +243,58 @@
<div class="tab-panel-content"> <div class="tab-panel-content">
<div class="info-group"> <div class="info-group">
<label><i class="bi bi-link-45deg"></i> URL:</label> <label><i class="bi bi-link-45deg"></i> URL:</label>
<div class="input-group"> <div class="input-group input-group-sm">
<input type="text" class="form-control" value="@selectedAuction.OriginalUrl" readonly /> <input type="text" class="form-control form-control-sm" value="@selectedAuction.OriginalUrl" readonly />
<button class="btn btn-outline-secondary" @onclick="() => CopyToClipboard(selectedAuction.OriginalUrl)" title="Copia"> <button class="btn btn-outline-secondary btn-sm" @onclick="() => CopyToClipboard(selectedAuction.OriginalUrl)" title="Copia">
<i class="bi bi-clipboard"></i> <i class="bi bi-clipboard"></i>
</button> </button>
<button class="btn btn-outline-primary btn-sm" @onclick="() => OpenAuctionInNewTab(selectedAuction.OriginalUrl)" title="Apri">
<i class="bi bi-box-arrow-up-right"></i>
</button>
</div> </div>
</div> </div>
<div class="row"> <div class="settings-grid-compact">
<div class="col-md-6 info-group"> <div class="setting-item">
<label><i class="bi bi-speedometer2"></i> Anticipo (ms):</label> <label><i class="bi bi-speedometer2"></i> Anticipo (ms)</label>
<input type="number" class="form-control" @bind="selectedAuction.BidBeforeDeadlineMs" @bind:after="SaveAuctions" /> <input type="number" class="form-control form-control-sm input-narrow" @bind="selectedAuction.BidBeforeDeadlineMs" @bind:after="SaveAuctions" />
</div> </div>
<div class="col-md-6 info-group"> <div class="setting-item">
<label><i class="bi bi-hand-index-thumb"></i> Max Click:</label> <label><i class="bi bi-currency-euro"></i> Min €</label>
<input type="number" class="form-control" @bind="selectedAuction.MaxClicks" @bind:after="SaveAuctions" /> <input type="number" step="0.01" class="form-control form-control-sm input-narrow" @bind="selectedAuction.MinPrice" @bind:after="SaveAuctions" />
</div>
<div class="setting-item">
<label><i class="bi bi-currency-euro"></i> Max €</label>
<input type="number" step="0.01" class="form-control form-control-sm input-narrow" @bind="selectedAuction.MaxPrice" @bind:after="SaveAuctions" />
</div>
<div class="setting-item">
<label><i class="bi bi-hand-index-thumb"></i> Max Puntate</label>
<input type="number" class="form-control form-control-sm input-narrow" @bind="selectedAuction.MaxBidsOverride" @bind:after="SaveAuctions" placeholder="0=?" />
</div> </div>
</div> </div>
<div class="row"> <div class="mt-2 pt-2 border-top">
<div class="col-md-6 info-group"> <button class="btn btn-outline-primary btn-sm w-100"
<label><i class="bi bi-currency-euro"></i> Min €:</label> @onclick="ApplyRecommendedLimitsToSelected"
<input type="number" step="0.01" class="form-control" @bind="selectedAuction.MinPrice" @bind:after="SaveAuctions" /> disabled="@isLoadingRecommendations">
@if (isLoadingRecommendations)
{
<span class="spinner-border spinner-border-sm me-1"></span>
<span>Caricamento...</span>
}
else
{
<i class="bi bi-magic me-1"></i>
<span>Applica Limiti Consigliati</span>
}
</button>
@if (!string.IsNullOrEmpty(recommendationMessage))
{
<div class="alert @(recommendationSuccess ? "alert-success" : "alert-warning") mt-2 mb-0 py-1 small">
<i class="bi @(recommendationSuccess ? "bi-check-circle" : "bi-exclamation-triangle") me-1"></i>
@recommendationMessage
</div> </div>
<div class="col-md-6 info-group"> }
<label><i class="bi bi-currency-euro"></i> Max €:</label>
<input type="number" step="0.01" class="form-control" @bind="selectedAuction.MaxPrice" @bind:after="SaveAuctions" />
</div>
</div>
<div class="row">
<div class="col-md-6 info-group">
<label><i class="bi bi-arrow-repeat"></i> Min Reset:</label>
<input type="number" class="form-control" @bind="selectedAuction.MinResets" @bind:after="SaveAuctions" />
</div>
<div class="col-md-6 info-group">
<label><i class="bi bi-arrow-repeat"></i> Max Reset:</label>
<input type="number" class="form-control" @bind="selectedAuction.MaxResets" @bind:after="SaveAuctions" />
</div>
</div>
<div class="form-check mt-2">
<input type="checkbox" class="form-check-input" id="checkOpen" @bind="selectedAuction.CheckAuctionOpenBeforeBid" @bind:after="SaveAuctions" />
<label class="form-check-label" for="checkOpen">
Verifica asta aperta prima di puntare
</label>
</div> </div>
</div> </div>
</div> </div>
@@ -279,7 +304,6 @@
<div class="tab-panel-content"> <div class="tab-panel-content">
@if (selectedAuction.CalculatedValue != null) @if (selectedAuction.CalculatedValue != null)
{ {
<!-- Sezione Principale - Compatta -->
<div class="product-info-compact"> <div class="product-info-compact">
<div class="info-cards"> <div class="info-cards">
<div class="info-card primary"> <div class="info-card primary">
@@ -289,7 +313,6 @@
<strong>@GetBuyNowPriceDisplay(selectedAuction)</strong> <strong>@GetBuyNowPriceDisplay(selectedAuction)</strong>
</div> </div>
</div> </div>
<div class="info-card info"> <div class="info-card info">
<i class="bi bi-truck"></i> <i class="bi bi-truck"></i>
<div> <div>
@@ -298,41 +321,33 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Calcoli in linea -->
<div class="calc-inline"> <div class="calc-inline">
<div class="calc-item"> <div class="calc-item">
<i class="bi bi-currency-euro"></i> <i class="bi bi-currency-euro"></i>
<span class="label">Prezzo attuale</span> <span class="label">Prezzo attuale</span>
<span class="value">€@selectedAuction.CalculatedValue.CurrentPrice.ToString("F2")</span> <span class="value">€@selectedAuction.CalculatedValue.CurrentPrice.ToString("F2")</span>
</div> </div>
<div class="calc-item"> <div class="calc-item">
<i class="bi bi-hand-index"></i> <i class="bi bi-hand-index"></i>
<span class="label">Totale puntate</span> <span class="label">Totale puntate</span>
<span class="value">@selectedAuction.CalculatedValue.TotalBids</span> <span class="value">@selectedAuction.CalculatedValue.TotalBids</span>
</div> </div>
<div class="calc-item highlight"> <div class="calc-item highlight">
<i class="bi bi-person-check-fill"></i> <i class="bi bi-person-check-fill"></i>
<span class="label">Tue puntate</span> <span class="label">Tue puntate</span>
<span class="value">@selectedAuction.CalculatedValue.MyBids</span> <span class="value">@selectedAuction.CalculatedValue.MyBids</span>
</div> </div>
<div class="calc-item"> <div class="calc-item">
<i class="bi bi-cash-coin"></i> <i class="bi bi-cash-coin"></i>
<span class="label">Costo puntate</span> <span class="label">Costo puntate</span>
<span class="value">€@selectedAuction.CalculatedValue.MyBidsCost.ToString("F2")</span> <span class="value">€@selectedAuction.CalculatedValue.MyBidsCost.ToString("F2")</span>
</div> </div>
</div> </div>
<!-- Totali compatti -->
<div class="totals-compact"> <div class="totals-compact">
<div class="total-item warning"> <div class="total-item warning">
<span>Costo Totale se vinci</span> <span>Costo Totale se vinci</span>
<strong>€@selectedAuction.CalculatedValue.TotalCostIfWin.ToString("F2")</strong> <strong>€@selectedAuction.CalculatedValue.TotalCostIfWin.ToString("F2")</strong>
</div> </div>
<div class="total-item @(selectedAuction.CalculatedValue.Savings > 0 ? "success" : "danger")"> <div class="total-item @(selectedAuction.CalculatedValue.Savings > 0 ? "success" : "danger")">
<span> <span>
<i class="bi bi-@(selectedAuction.CalculatedValue.Savings > 0 ? "arrow-down-circle-fill" : "arrow-up-circle-fill")"></i> <i class="bi bi-@(selectedAuction.CalculatedValue.Savings > 0 ? "arrow-down-circle-fill" : "arrow-up-circle-fill")"></i>
@@ -340,25 +355,17 @@
</span> </span>
<strong>@GetSavingsDisplay(selectedAuction)</strong> <strong>@GetSavingsDisplay(selectedAuction)</strong>
</div> </div>
<div class="verdict-badge @(selectedAuction.CalculatedValue.Savings > 0 ? "success" : "danger")"> <div class="verdict-badge @(selectedAuction.CalculatedValue.Savings > 0 ? "success" : "danger")">
<i class="bi bi-@(selectedAuction.CalculatedValue.Savings > 0 ? "check-circle-fill" : "x-circle-fill")"></i> <i class="bi bi-@(selectedAuction.CalculatedValue.Savings > 0 ? "check-circle-fill" : "x-circle-fill")"></i>
@(selectedAuction.CalculatedValue.Savings > 0 ? "Conveniente!" : "Non conveniente") @(selectedAuction.CalculatedValue.Savings > 0 ? "Conveniente!" : "Non conveniente")
</div> </div>
</div> </div>
</div> </div>
@if (!string.IsNullOrEmpty(selectedAuction.CalculatedValue.Summary))
{
<div class="alert alert-info mt-3 mb-0">
<i class="bi bi-info-circle"></i> @selectedAuction.CalculatedValue.Summary
</div>
}
} }
else else
{ {
<div class="alert alert-secondary"> <div class="alert alert-secondary">
<i class="bi bi-hourglass-split"></i> Informazioni prodotto non ancora disponibili. Verranno caricate automaticamente. <i class="bi bi-hourglass-split"></i> Informazioni prodotto non ancora disponibili.
</div> </div>
} }
</div> </div>
@@ -367,29 +374,32 @@
<!-- TAB STORIA PUNTATE --> <!-- TAB STORIA PUNTATE -->
<div class="tab-pane fade" id="content-history" role="tabpanel"> <div class="tab-pane fade" id="content-history" role="tabpanel">
<div class="tab-panel-content"> <div class="tab-panel-content">
@if (selectedAuction.RecentBids != null && selectedAuction.RecentBids.Any()) @{
var recentBidsList = GetRecentBidsSafe(selectedAuction);
var filteredBids = new List<BidHistoryEntry>();
BidHistoryEntry? lastBid = null;
foreach (var bid in recentBidsList)
{
if (lastBid != null &&
Math.Abs(bid.Price - lastBid.Price) < 0.001m &&
bid.Username.Equals(lastBid.Username, StringComparison.OrdinalIgnoreCase))
{
continue;
}
filteredBids.Add(bid);
lastBid = bid;
}
}
@if (filteredBids.Any())
{ {
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-sm table-striped"> <table class="table table-sm table-striped">
<thead> <thead><tr><th>Utente</th><th>Prezzo</th><th>Data/Ora</th><th>Tipo</th></tr></thead>
<tr>
<th>Utente</th>
<th>Prezzo</th>
<th>Data/Ora</th>
<th>Tipo</th>
</tr>
</thead>
<tbody> <tbody>
@foreach (var bid in selectedAuction.RecentBids.Take(50)) @foreach (var bid in filteredBids.Take(50))
{ {
<tr class="@(bid.IsMyBid ? "table-success" : "")"> <tr class="@(bid.IsMyBid ? "my-bid-row" : "")">
<td> <td>@if (bid.IsMyBid){<strong class="text-success">@bid.Username</strong><span class="badge bg-success ms-1">TU</span>}else{@bid.Username}</td>
@bid.Username
@if (bid.IsMyBid)
{
<span class="badge bg-success ms-1">TU</span>
}
</td>
<td class="fw-bold">€@bid.PriceFormatted</td> <td class="fw-bold">€@bid.PriceFormatted</td>
<td class="text-muted small">@bid.TimeFormatted</td> <td class="text-muted small">@bid.TimeFormatted</td>
<td><span class="badge bg-secondary">@bid.BidType</span></td> <td><span class="badge bg-secondary">@bid.BidType</span></td>
@@ -401,9 +411,7 @@
} }
else else
{ {
<div class="alert alert-secondary"> <div class="alert alert-secondary"><i class="bi bi-inbox"></i> Nessuna puntata registrata.</div>
<i class="bi bi-inbox"></i> Nessuna puntata registrata per questa asta.
</div>
} }
</div> </div>
</div> </div>
@@ -411,52 +419,29 @@
<!-- TAB PUNTATORI --> <!-- TAB PUNTATORI -->
<div class="tab-pane fade" id="content-bidders" role="tabpanel"> <div class="tab-pane fade" id="content-bidders" role="tabpanel">
<div class="tab-panel-content"> <div class="tab-panel-content">
@if (selectedAuction.RecentBids != null && selectedAuction.RecentBids.Any()) @{
var bidderStatsCopy = selectedAuction.BidderStats.Values.OrderByDescending(b => b.BidCount).ToList();
var myOfficialBidsCount = selectedAuction.BidsUsedOnThisAuction ?? 0;
var currentUsername = GetCurrentUsername();
}
@if (bidderStatsCopy.Any())
{ {
// Crea una copia locale per evitare modifiche durante l'enumerazione var totalBidsCumulative = bidderStatsCopy.Sum(b => b.BidCount);
var recentBidsCopy = selectedAuction.RecentBids.ToList();
var bidderStats = recentBidsCopy
.GroupBy(b => b.Username)
.Select(g => new { Username = g.Key, Count = g.Count(), IsMe = g.First().IsMyBid })
.OrderByDescending(s => s.Count)
.ToList();
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-sm table-striped"> <table class="table table-sm table-striped">
<thead> <thead><tr><th>#</th><th>Utente</th><th>Puntate</th><th>%</th></tr></thead>
<tr>
<th>Posizione</th>
<th>Utente</th>
<th>Puntate</th>
<th>Percentuale</th>
</tr>
</thead>
<tbody> <tbody>
@for (int i = 0; i < bidderStats.Count; i++) @for (int i = 0; i < bidderStatsCopy.Count; i++)
{ {
var bidder = bidderStats[i]; var bidder = bidderStatsCopy[i];
var percentage = (bidder.Count * 100.0 / recentBidsCopy.Count); var isMe = bidder.Username.Equals(currentUsername, StringComparison.OrdinalIgnoreCase);
<tr class="@(bidder.IsMe ? "table-success" : "")"> var displayCount = isMe && myOfficialBidsCount > bidder.BidCount ? myOfficialBidsCount : bidder.BidCount;
<td><span class="badge bg-primary">#{i + 1}</span></td> var percentage = totalBidsCumulative > 0 ? (displayCount * 100.0 / totalBidsCumulative) : 0;
<td> <tr class="@(isMe ? "my-bid-row" : "")">
@bidder.Username <td><span class="badge bg-primary">#@(i + 1)</span></td>
@if (bidder.IsMe) <td>@if (isMe){<strong class="text-success">@bidder.Username</strong>}else{@bidder.Username}</td>
{ <td class="fw-bold">@displayCount</td>
<span class="badge bg-success ms-1">TU</span> <td><div class="progress" style="height:16px;"><div class="progress-bar @(isMe?"bg-success":"bg-primary")" style="width:@percentage.ToString("F0")%">@percentage.ToString("F0")%</div></div></td>
}
</td>
<td class="fw-bold">@bidder.Count</td>
<td>
<div class="progress" style="height: 20px;">
<div class="progress-bar @(bidder.IsMe ? "bg-success" : "bg-primary")"
role="progressbar"
style="width: @percentage.ToString("F1")%">
@percentage.ToString("F1")%
</div>
</div>
</td>
</tr> </tr>
} }
</tbody> </tbody>
@@ -465,43 +450,63 @@
} }
else else
{ {
<div class="alert alert-secondary"> <div class="alert alert-secondary"><i class="bi bi-inbox"></i> Nessun dato sui puntatori.</div>
<i class="bi bi-inbox"></i> Nessun dato sui puntatori disponibile.
</div>
} }
</div> </div>
</div> </div>
<!-- TAB LOG --> <!-- TAB LOG -->
<div class="tab-pane fade" id="content-log" role="tabpanel"> <div class="tab-pane fade" id="content-log" role="tabpanel">
<div class="tab-panel-content"> <div class="tab-panel-content p-0">
<div class="log-box-compact">
@if (selectedAuction.AuctionLog.Any()) @if (selectedAuction.AuctionLog.Any())
{ {
@foreach (var logEntry in GetAuctionLog(selectedAuction)) <div class="auction-log-grid">
<div class="alog-header">
<span class="alog-col-time">Ora</span>
<span class="alog-col-level">Livello</span>
<span class="alog-col-cat">Categoria</span>
<span class="alog-col-msg">Messaggio</span>
</div>
<div class="alog-body">
@foreach (var entry in GetAuctionLog(selectedAuction))
{ {
<div class="log-entry">@logEntry</div> <div class="alog-row @entry.LevelClass">
<span class="alog-col-time">@entry.TimeDisplay</span>
<span class="alog-col-level">
<i class="bi @entry.LevelIcon"></i> @entry.LevelLabel
</span>
<span class="alog-col-cat">@entry.CategoryLabel</span>
<span class="alog-col-msg">
@entry.Message
@if (entry.RepeatCount > 1)
{
<span class="alog-repeat">x@entry.RepeatCount</span>
} }
</span>
</div>
}
</div>
</div>
} }
else else
{ {
<div class="text-muted"><i class="bi bi-inbox"></i> Nessun log disponibile per questa asta.</div> <div class="text-muted p-3"><i class="bi bi-inbox"></i> Nessun log disponibile.</div>
} }
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div>
} }
else else
{ {
<div class="auction-details-tabs animate-fade-in shadow-hover"> <div class="details-placeholder">
<div class="alert alert-secondary text-center my-5"> <i class="bi bi-arrow-up"></i>
<i class="bi bi-arrow-up" style="font-size: 2rem; display: block; margin-bottom: 0.5rem;"></i> <p>Seleziona un'asta per visualizzare i dettagli</p>
<p class="mb-0">Seleziona un'asta dalla griglia per visualizzare i dettagli</p>
</div>
</div> </div>
} }
</div>
</div>
</div>
</div> </div>
<!-- Modal Aggiungi Asta --> <!-- Modal Aggiungi Asta -->
@@ -509,7 +514,7 @@
{ {
<div class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.5);"> <div class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.5);">
<div class="modal-dialog modal-dialog-centered"> <div class="modal-dialog modal-dialog-centered">
<div class="modal-content animate-scale-in"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title"><i class="bi bi-plus-circle"></i> Aggiungi Nuova Asta</h5> <h5 class="modal-title"><i class="bi bi-plus-circle"></i> Aggiungi Nuova Asta</h5>
<button type="button" class="btn-close" @onclick="CloseAddDialog"></button> <button type="button" class="btn-close" @onclick="CloseAddDialog"></button>
@@ -517,34 +522,126 @@
<div class="modal-body"> <div class="modal-body">
<div class="mb-3"> <div class="mb-3">
<label class="form-label fw-bold"><i class="bi bi-link-45deg"></i> URL Asta:</label> <label class="form-label fw-bold"><i class="bi bi-link-45deg"></i> URL Asta:</label>
<input type="text" class="form-control transition-colors @(addDialogError != null ? "is-invalid" : "")" <input type="text" class="form-control @(addDialogError != null ? "is-invalid" : "")"
@bind="addDialogUrl" @bind="addDialogUrl"
placeholder="https://it.bidoo.com/asta/..." /> placeholder="https://it.bidoo.com/asta/..." />
@if (addDialogError != null) @if (addDialogError != null)
{ {
<div class="invalid-feedback d-block animate-shake"> <div class="invalid-feedback d-block">
<i class="bi bi-exclamation-triangle"></i> @addDialogError <i class="bi bi-exclamation-triangle"></i> @addDialogError
</div> </div>
} }
<small class="form-text text-muted">
<i class="bi bi-info-circle"></i> Inserisci l'URL completo dell'asta da Bidoo.com. Il nome sarà rilevato automaticamente.
</small>
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-secondary hover-lift" @onclick="CloseAddDialog"> <button type="button" class="btn btn-secondary" @onclick="CloseAddDialog">Annulla</button>
<i class="bi bi-x-circle"></i> Annulla <button type="button" class="btn btn-primary" @onclick="AddAuction" disabled="@string.IsNullOrWhiteSpace(addDialogUrl)">Aggiungi</button>
</button>
<button type="button" class="btn btn-primary hover-lift" @onclick="AddAuction" disabled="@string.IsNullOrWhiteSpace(addDialogUrl)">
<i class="bi bi-plus-lg"></i> Aggiungi
</button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
} }
<!-- Versione in basso a destra --> @* Script Splitter - Versione Fixed senza sovrapposizioni *@
<div class="version-badge"> <script suppress-error="BL9992">
<i class="bi bi-box-seam"></i> v1.0.0 (function() {
</div> function initSplitters() {
const gutterV = document.getElementById('gutterVertical');
const gutterH = document.getElementById('gutterHorizontal');
const panelAuctions = document.getElementById('panelAuctions');
const panelLog = document.getElementById('panelLog');
const topRow = document.getElementById('topRow');
const bottomRow = document.getElementById('bottomRow');
if (!gutterV || !gutterH || !panelAuctions || !panelLog || !topRow || !bottomRow) {
setTimeout(initSplitters, 200);
return;
}
let active = null;
let startPos = 0;
let startSizeA = 0;
let startSizeB = 0;
let containerSize = 0;
function onMouseDown(e, type, elA, elB) {
active = { type, elA, elB };
startPos = type === 'v' ? e.clientX : e.clientY;
// Calcola dimensioni attuali
if (type === 'v') {
startSizeA = elA.offsetWidth;
startSizeB = elB.offsetWidth;
containerSize = elA.parentElement.offsetWidth - gutterV.offsetWidth;
} else {
startSizeA = elA.offsetHeight;
startSizeB = elB.offsetHeight;
containerSize = elA.parentElement.offsetHeight - gutterH.offsetHeight;
}
document.body.style.cursor = type === 'v' ? 'col-resize' : 'row-resize';
document.body.style.userSelect = 'none';
e.preventDefault();
}
gutterV.onmousedown = (e) => onMouseDown(e, 'v', panelAuctions, panelLog);
gutterH.onmousedown = (e) => onMouseDown(e, 'h', topRow, bottomRow);
document.onmousemove = function(e) {
if (!active) return;
const { type, elA, elB } = active;
const pos = type === 'v' ? e.clientX : e.clientY;
const diff = pos - startPos;
let newA = startSizeA + diff;
let newB = startSizeB - diff;
// Limiti minimi
const minA = type === 'v' ? 300 : 200;
const minB = type === 'v' ? 200 : 150;
// Applica limiti
if (newA < minA) {
newA = minA;
newB = containerSize - newA;
}
if (newB < minB) {
newB = minB;
newA = containerSize - newB;
}
// Assicura che la somma sia corretta (no sovrapposizioni/gap)
const totalCheck = newA + newB;
if (Math.abs(totalCheck - containerSize) > 1) {
// Normalizza le dimensioni
const ratio = containerSize / totalCheck;
newA = Math.round(newA * ratio);
newB = containerSize - newA;
}
// Applica le nuove dimensioni con flex none per dimensioni fisse
if (type === 'v') {
elA.style.width = newA + 'px';
elA.style.flex = 'none';
elB.style.width = newB + 'px';
elB.style.flex = 'none';
} else {
elA.style.height = newA + 'px';
elA.style.flex = 'none';
elB.style.height = newB + 'px';
elB.style.flex = 'none';
}
};
document.onmouseup = function() {
if (active) {
active = null;
document.body.style.cursor = '';
document.body.style.userSelect = '';
}
};
}
setTimeout(initSplitters, 300);
})();
</script>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+4
View File
@@ -9,9 +9,13 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="~/" /> <base href="~/" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet" /> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet" /> <link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet" />
<link href="css/app-wpf.css" rel="stylesheet" /> <link href="css/app-wpf.css" rel="stylesheet" />
<link href="css/modern-pages.css" rel="stylesheet" />
<link href="css/animations.css" rel="stylesheet" /> <link href="css/animations.css" rel="stylesheet" />
<component type="typeof(HeadOutlet)" render-mode="ServerPrerendered" /> <component type="typeof(HeadOutlet)" render-mode="ServerPrerendered" />
</head> </head>
+378 -221
View File
@@ -1,33 +1,101 @@
using AutoBidder.Services; using AutoBidder.Services;
using AutoBidder.Data; using AutoBidder.Data;
using AutoBidder.Models;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.DataProtection;
using System.Data.Common;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
// Configura Kestrel per accesso remoto con supporto HTTPS // FORCE ASPNETCORE_URLS to prevent any override
builder.WebHost.ConfigureKestrel(options => // Questo garantisce che il container ascolti SEMPRE sulla porta configurata
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("ASPNETCORE_URLS")))
{ {
options.ListenAnyIP(5000); // HTTP builder.WebHost.UseUrls("http://+:8080");
}
else
{
builder.WebHost.UseUrls(Environment.GetEnvironmentVariable("ASPNETCORE_URLS")!);
}
// Configura Kestrel solo per HTTPS opzionale
// HTTP è gestito da ASPNETCORE_URLS (default: http://+:8080 nel Dockerfile)
var enableHttps = builder.Configuration.GetValue<bool>("Kestrel:EnableHttps", false);
if (enableHttps)
{
builder.WebHost.ConfigureKestrel(options =>
{
try
{
// In produzione, cerca il certificato da configurazione
var certPath = builder.Configuration["Kestrel:Certificates:Default:Path"];
var certPassword = builder.Configuration["Kestrel:Certificates:Default:Password"];
if (!string.IsNullOrEmpty(certPath) && File.Exists(certPath))
{
options.ListenAnyIP(8443, listenOptions =>
{
listenOptions.UseHttps(certPath, certPassword);
Console.WriteLine($"[Kestrel] HTTPS enabled with certificate: {certPath}");
});
}
else if (builder.Environment.IsDevelopment())
{
// Certificato di sviluppo SOLO in ambiente Development
options.ListenAnyIP(5001, listenOptions => options.ListenAnyIP(5001, listenOptions =>
{ {
listenOptions.UseHttps(); // HTTPS listenOptions.UseHttps();
Console.WriteLine("[Kestrel] HTTPS enabled with development certificate");
}); });
}); }
else
{
Console.WriteLine("[Kestrel] HTTPS requested but no certificate found");
Console.WriteLine("[Kestrel] Running in HTTP-only mode");
}
}
catch (Exception ex)
{
Console.WriteLine($"[Kestrel] Failed to enable HTTPS: {ex.Message}");
Console.WriteLine("[Kestrel] Running in HTTP-only mode");
}
});
}
else
{
Console.WriteLine("[Kestrel] HTTPS disabled - running in HTTP-only mode");
Console.WriteLine("[Kestrel] Use a reverse proxy (nginx/traefik) for SSL termination");
Console.WriteLine($"[Kestrel] Listening on: {Environment.GetEnvironmentVariable("ASPNETCORE_URLS") ?? "http://+:8080"}");
}
// Add services to the container // Add services to the container
builder.Services.AddRazorPages(); builder.Services.AddRazorPages();
builder.Services.AddServerSideBlazor(); builder.Services.AddServerSideBlazor();
// ============================================
// CONFIGURAZIONE DATABASE - Path configurabile via DATA_PATH
// ============================================
// Determina il path base per tutti i database e dati persistenti
// DATA_PATH può essere impostato nel docker-compose per usare un volume persistente
var dataBasePath = Environment.GetEnvironmentVariable("DATA_PATH");
if (string.IsNullOrEmpty(dataBasePath))
{
// Fallback: usa directory relativa all'applicazione
dataBasePath = Path.Combine(AppContext.BaseDirectory, "Data");
}
// Crea directory se non esiste
if (!Directory.Exists(dataBasePath))
{
Directory.CreateDirectory(dataBasePath);
}
Console.WriteLine($"[Startup] Data path: {dataBasePath}");
// Configura Data Protection per evitare CryptographicException // Configura Data Protection per evitare CryptographicException
var dataProtectionPath = Path.Combine( var dataProtectionPath = Path.Combine(dataBasePath, "DataProtection-Keys");
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"AutoBidder",
"DataProtection-Keys"
);
if (!Directory.Exists(dataProtectionPath)) if (!Directory.Exists(dataProtectionPath))
{ {
@@ -38,6 +106,57 @@ builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(dataProtectionPath)) .PersistKeysToFileSystem(new DirectoryInfo(dataProtectionPath))
.SetApplicationName("AutoBidder"); .SetApplicationName("AutoBidder");
// Database per Identity (SQLite)
var identityDbPath = Path.Combine(dataBasePath, "identity.db");
builder.Services.AddDbContext<ApplicationDbContext>(options =>
{
options.UseSqlite($"Data Source={identityDbPath}");
});
// ASP.NET Core Identity
builder.Services.AddIdentity<ApplicationUser, IdentityRole>(options =>
{
// Password settings (SICUREZZA FORTE)
options.Password.RequireDigit = true;
options.Password.RequireLowercase = true;
options.Password.RequireUppercase = true;
options.Password.RequireNonAlphanumeric = true;
options.Password.RequiredLength = 12;
options.Password.RequiredUniqueChars = 4;
// Lockout settings (protezione brute-force)
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15);
options.Lockout.MaxFailedAccessAttempts = 5;
options.Lockout.AllowedForNewUsers = true;
// User settings
options.User.RequireUniqueEmail = false;
options.SignIn.RequireConfirmedAccount = false;
})
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddDefaultTokenProviders();
// Cookie configuration (SICUREZZA TAILSCALE)
builder.Services.ConfigureApplicationCookie(options =>
{
options.Cookie.Name = "AutoBidder.Auth";
options.Cookie.HttpOnly = true;
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest; // HTTP su Tailscale OK
options.Cookie.SameSite = SameSiteMode.Lax;
options.ExpireTimeSpan = TimeSpan.FromDays(7);
options.SlidingExpiration = true;
// Redirect per autenticazione (Razor Pages)
options.LoginPath = "/Account/Login";
options.LogoutPath = "/Account/Logout";
options.AccessDeniedPath = "/Account/Login";
});
// Authorization
builder.Services.AddAuthorization();
builder.Services.AddCascadingAuthenticationState();
// Configura HTTPS Redirection per produzione // Configura HTTPS Redirection per produzione
if (!builder.Environment.IsDevelopment()) if (!builder.Environment.IsDevelopment())
{ {
@@ -49,71 +168,6 @@ if (!builder.Environment.IsDevelopment())
}); });
} }
// Configura Database SQLite per statistiche (fallback locale)
builder.Services.AddDbContext<StatisticsContext>(options =>
{
var dbPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"AutoBidder",
"statistics.db"
);
// Crea directory se non esiste
var directory = Path.GetDirectoryName(dbPath);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
options.UseSqlite($"Data Source={dbPath}");
});
// Configura Database PostgreSQL per statistiche avanzate
var usePostgres = builder.Configuration.GetValue<bool>("Database:UsePostgres", false);
if (usePostgres)
{
try
{
var connString = builder.Environment.IsProduction()
? builder.Configuration.GetConnectionString("PostgresStatsProduction")
: builder.Configuration.GetConnectionString("PostgresStats");
// Sostituisci variabili ambiente in production
if (builder.Environment.IsProduction())
{
connString = connString?
.Replace("${POSTGRES_USER}", Environment.GetEnvironmentVariable("POSTGRES_USER") ?? "autobidder")
.Replace("${POSTGRES_PASSWORD}", Environment.GetEnvironmentVariable("POSTGRES_PASSWORD") ?? "");
}
if (!string.IsNullOrEmpty(connString))
{
builder.Services.AddDbContext<AutoBidder.Data.PostgresStatsContext>(options =>
{
options.UseNpgsql(connString, npgsqlOptions =>
{
npgsqlOptions.EnableRetryOnFailure(3);
npgsqlOptions.CommandTimeout(30);
});
});
Console.WriteLine("[Startup] PostgreSQL configured for statistics");
}
else
{
Console.WriteLine("[Startup] PostgreSQL connection string not found - using SQLite only");
}
}
catch (Exception ex)
{
Console.WriteLine($"[Startup] PostgreSQL configuration failed: {ex.Message} - using SQLite only");
}
}
else
{
Console.WriteLine("[Startup] PostgreSQL disabled in configuration - using SQLite only");
}
// Registra servizi applicazione come Singleton per condividere stato // Registra servizi applicazione come Singleton per condividere stato
var htmlCacheService = new HtmlCacheService( var htmlCacheService = new HtmlCacheService(
maxConcurrentRequests: 3, maxConcurrentRequests: 3,
@@ -122,31 +176,18 @@ var htmlCacheService = new HtmlCacheService(
maxRetries: 2 maxRetries: 2
); );
var auctionMonitor = new AuctionMonitor(); var bidStrategyService = new BidStrategyService();
var auctionMonitor = new AuctionMonitor(bidStrategyService);
htmlCacheService.OnLog += (msg) => Console.WriteLine(msg); htmlCacheService.OnLog += (msg) => Console.WriteLine(msg);
builder.Services.AddSingleton(bidStrategyService);
builder.Services.AddSingleton(auctionMonitor); builder.Services.AddSingleton(auctionMonitor);
builder.Services.AddSingleton(htmlCacheService); builder.Services.AddSingleton(htmlCacheService);
builder.Services.AddSingleton(sp => new SessionService(auctionMonitor.GetApiClient())); builder.Services.AddSingleton(sp => new SessionService(auctionMonitor.GetApiClient()));
builder.Services.AddSingleton<DatabaseService>(); builder.Services.AddSingleton<DatabaseService>();
builder.Services.AddSingleton<ApplicationStateService>(); builder.Services.AddSingleton<ApplicationStateService>();
builder.Services.AddScoped<StatsService>(sp => builder.Services.AddSingleton<BidooBrowserService>();
{ builder.Services.AddScoped<StatsService>();
var db = sp.GetRequiredService<DatabaseService>();
// Prova a ottenere PostgreSQL context (potrebbe essere null)
AutoBidder.Data.PostgresStatsContext? postgresDb = null;
try
{
postgresDb = sp.GetService<AutoBidder.Data.PostgresStatsContext>();
}
catch
{
// PostgreSQL non disponibile, usa solo SQLite
}
return new StatsService(db, postgresDb);
});
builder.Services.AddScoped<AuctionStateService>(); builder.Services.AddScoped<AuctionStateService>();
// Configura SignalR per real-time updates // Configura SignalR per real-time updates
@@ -158,6 +199,63 @@ builder.Services.AddSignalR(options =>
var app = builder.Build(); var app = builder.Build();
// ============================================
// INIZIALIZZAZIONE DATABASE IDENTITY
// ============================================
using (var scope = app.Services.CreateScope())
{
try
{
var identityDb = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
// Crea database Identity
await identityDb.Database.EnsureCreatedAsync();
Console.WriteLine("[Identity] Database initialized");
// Crea utente admin se non esiste
var adminUsername = Environment.GetEnvironmentVariable("ADMIN_USERNAME") ?? "admin";
var adminPassword = Environment.GetEnvironmentVariable("ADMIN_PASSWORD");
// Password di default se non configurata (stessa per debug e container)
if (string.IsNullOrEmpty(adminPassword))
{
adminPassword = "Admin@Password123!";
}
var existingAdmin = await userManager.FindByNameAsync(adminUsername);
if (existingAdmin == null)
{
var adminUser = new ApplicationUser
{
UserName = adminUsername,
Email = $"{adminUsername}@autobidder.local",
EmailConfirmed = true,
IsActive = true,
CreatedAt = DateTime.UtcNow
};
var result = await userManager.CreateAsync(adminUser, adminPassword);
if (result.Succeeded)
{
Console.WriteLine($"[Identity] Admin user created: {adminUsername}");
}
else
{
Console.WriteLine($"[Identity] Failed to create admin user: {string.Join(", ", result.Errors.Select(e => e.Description))}");
}
}
else
{
Console.WriteLine($"[Identity] Admin user exists: {adminUsername}");
}
}
catch (Exception ex)
{
Console.WriteLine($"[Identity] Initialization error: {ex.Message}");
}
}
// ??? NUOVO: Inizializza DatabaseService // ??? NUOVO: Inizializza DatabaseService
using (var scope = app.Services.CreateScope()) using (var scope = app.Services.CreateScope())
{ {
@@ -180,139 +278,126 @@ using (var scope = app.Services.CreateScope())
// Verifica salute database // Verifica salute database
var isHealthy = await databaseService.CheckDatabaseHealthAsync(); var isHealthy = await databaseService.CheckDatabaseHealthAsync();
Console.WriteLine($"[DB] Database health check: {(isHealthy ? "OK" : "FAILED")}"); Console.WriteLine($"[DB] Database health check: {(isHealthy ? "OK" : "FAILED")}");
// 🔥 MANUTENZIONE AUTOMATICA DATABASE
var settings = AutoBidder.Utilities.SettingsManager.Load();
if (settings.DatabaseAutoCleanupDuplicates)
{
Console.WriteLine("[DB] Checking for duplicate records...");
var duplicateCount = await databaseService.CountDuplicateAuctionResultsAsync();
if (duplicateCount > 0)
{
Console.WriteLine($"[DB] Found {duplicateCount} duplicates - removing...");
var removed = await databaseService.RemoveDuplicateAuctionResultsAsync();
Console.WriteLine($"[DB] ✓ Removed {removed} duplicate auction results");
}
else
{
Console.WriteLine("[DB] ✓ No duplicates found");
}
}
if (settings.DatabaseAutoCleanupIncomplete)
{
Console.WriteLine("[DB] Checking for incomplete records...");
var incompleteCount = await databaseService.CountIncompleteAuctionResultsAsync();
if (incompleteCount > 0)
{
Console.WriteLine($"[DB] Found {incompleteCount} incomplete records - removing...");
var removed = await databaseService.RemoveIncompleteAuctionResultsAsync();
Console.WriteLine($"[DB] ✓ Removed {removed} incomplete auction results");
}
else
{
Console.WriteLine("[DB] ✓ No incomplete records found");
}
}
if (settings.DatabaseMaxRetentionDays > 0)
{
Console.WriteLine($"[DB] Checking for records older than {settings.DatabaseMaxRetentionDays} days...");
var oldCount = await databaseService.RemoveOldAuctionResultsAsync(settings.DatabaseMaxRetentionDays);
if (oldCount > 0)
{
Console.WriteLine($"[DB] ✓ Removed {oldCount} old auction results");
}
else
{
Console.WriteLine($"[DB] ✓ No old records to remove");
}
}
// 🆕 Esegui diagnostica completa se ci sono problemi o se richiesto
var runDiagnostics = Environment.GetEnvironmentVariable("DB_DIAGNOSTICS")?.ToLower() == "true";
if (!isHealthy || runDiagnostics)
{
Console.WriteLine("[DB] Running full diagnostics...");
await databaseService.RunDatabaseDiagnosticsAsync();
}
} }
catch (Exception ex) catch (Exception ex)
{ {
Console.WriteLine($"[DB ERROR] Failed to initialize database: {ex.Message}"); Console.WriteLine($"[DB ERROR] Failed to initialize database: {ex.Message}");
Console.WriteLine($"[DB ERROR] Stack trace: {ex.StackTrace}"); Console.WriteLine($"[DB ERROR] Stack trace: {ex.StackTrace}");
}
}
// Crea database statistiche se non esiste (senza migrations)
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<StatisticsContext>();
// In caso di errore, esegui sempre la diagnostica
try try
{ {
// Log percorso database await databaseService.RunDatabaseDiagnosticsAsync();
var connection = db.Database.GetDbConnection();
Console.WriteLine($"[STATS DB] Database path: {connection.DataSource}");
// Verifica se database esiste
var dbExists = db.Database.CanConnect();
Console.WriteLine($"[STATS DB] Database exists: {dbExists}");
// Forza creazione tabelle se non esistono
if (!dbExists || !db.ProductStats.Any())
{
Console.WriteLine("[STATS DB] Creating database schema...");
db.Database.EnsureDeleted(); // Elimina database vecchio
db.Database.EnsureCreated(); // Ricrea con schema aggiornato
Console.WriteLine("[STATS DB] Database schema created successfully");
}
else
{
Console.WriteLine($"[STATS DB] Database already exists with {db.ProductStats.Count()} records");
}
}
catch (Exception ex)
{
Console.WriteLine($"[STATS DB ERROR] Failed to initialize database: {ex.Message}");
Console.WriteLine($"[STATS DB ERROR] Stack trace: {ex.StackTrace}");
// Prova a ricreare forzatamente
try
{
Console.WriteLine("[STATS DB] Attempting forced recreation...");
db.Database.EnsureDeleted();
db.Database.EnsureCreated();
Console.WriteLine("[STATS DB] Forced recreation successful");
}
catch (Exception ex2)
{
Console.WriteLine($"[STATS DB ERROR] Forced recreation failed: {ex2.Message}");
}
}
}
// Inizializza PostgreSQL per statistiche avanzate
using (var scope = app.Services.CreateScope())
{
try
{
var postgresDb = scope.ServiceProvider.GetService<AutoBidder.Data.PostgresStatsContext>();
if (postgresDb != null)
{
Console.WriteLine("[PostgreSQL] Initializing PostgreSQL statistics database...");
var autoCreateSchema = app.Configuration.GetValue<bool>("Database:AutoCreateSchema", true);
if (autoCreateSchema)
{
// Usa il metodo EnsureSchemaAsync che gestisce la creazione automatica
var schemaCreated = await postgresDb.EnsureSchemaAsync();
if (schemaCreated)
{
// Valida che tutte le tabelle siano state create
var schemaValid = await postgresDb.ValidateSchemaAsync();
if (schemaValid)
{
Console.WriteLine("[PostgreSQL] Statistics features ENABLED");
}
else
{
Console.WriteLine("[PostgreSQL] Schema validation failed");
Console.WriteLine("[PostgreSQL] Statistics features DISABLED (missing tables)");
}
}
else
{
Console.WriteLine("[PostgreSQL] Cannot connect to database");
Console.WriteLine("[PostgreSQL] Statistics features will use SQLite fallback");
}
}
else
{
Console.WriteLine("[PostgreSQL] Auto-create schema disabled");
// Prova comunque a validare lo schema esistente
try
{
var schemaValid = await postgresDb.ValidateSchemaAsync();
if (schemaValid)
{
Console.WriteLine("[PostgreSQL] Existing schema validated successfully");
Console.WriteLine("[PostgreSQL] Statistics features ENABLED");
}
else
{
Console.WriteLine("[PostgreSQL] Statistics features DISABLED (schema not found)");
}
} }
catch catch
{ {
Console.WriteLine("[PostgreSQL] Statistics features DISABLED (schema not found)"); // Ignora errori nella diagnostica stessa
} }
} }
}
else
{
Console.WriteLine("[PostgreSQL] Not configured - Statistics will use SQLite only");
}
}
catch (Exception ex)
{
Console.WriteLine($"[PostgreSQL ERROR] Initialization failed: {ex.Message}");
Console.WriteLine($"[PostgreSQL ERROR] Stack trace: {ex.StackTrace}");
Console.WriteLine($"[PostgreSQL] Statistics features will use SQLite fallback");
}
} }
// ??? NUOVO: Ripristina aste salvate e riprendi monitoraggio se configurato // ?? NUOVO: Collega evento OnAuctionCompleted per salvare statistiche
{
var dbService = app.Services.GetRequiredService<DatabaseService>();
auctionMonitor.OnAuctionCompleted += async (auction, state, won) =>
{
try
{
Console.WriteLine($"");
Console.WriteLine($"╔════════════════════════════════════════════════════════════════");
Console.WriteLine($"║ [EVENTO] Asta Terminata - Salvataggio Statistiche");
Console.WriteLine($"╠════════════════════════════════════════════════════════════════");
Console.WriteLine($"║ Asta: {auction.Name}");
Console.WriteLine($"║ ID: {auction.AuctionId}");
Console.WriteLine($"║ Stato: {(won ? " VINTA" : " PERSA")}");
Console.WriteLine($"╚════════════════════════════════════════════════════════════════");
Console.WriteLine($"");
// Crea un nuovo scope per StatsService (è Scoped)
using var scope = app.Services.CreateScope();
var statsService = scope.ServiceProvider.GetRequiredService<StatsService>();
await statsService.RecordAuctionCompletedAsync(auction, state, won);
// ✅ CORRETTO: Log di successo SOLO se non ci sono eccezioni
Console.WriteLine($"");
Console.WriteLine($"[EVENTO] ✓ Asta salvata con successo nel database");
Console.WriteLine($"");
}
catch (Exception ex)
{
Console.WriteLine($"");
Console.WriteLine($"[EVENTO ERROR] ✗ Errore durante salvataggio statistiche:");
Console.WriteLine($"[EVENTO ERROR] {ex.Message}");
Console.WriteLine($"[EVENTO ERROR] Stack: {ex.StackTrace}");
Console.WriteLine($"");
}
};
Console.WriteLine("[STARTUP] OnAuctionCompleted event handler registered");
}
// ? NUOVO: Ripristina aste salvate e riprendi monitoraggio se configurato
using (var scope = app.Services.CreateScope()) using (var scope = app.Services.CreateScope())
{ {
try try
@@ -347,15 +432,26 @@ using (var scope = app.Services.CreateScope())
// Gestisci comportamento di avvio // Gestisci comportamento di avvio
if (settings.RememberAuctionStates) if (settings.RememberAuctionStates)
{ {
// Modalità "Ricorda Stato": mantiene lo stato salvato di ogni asta // Modalità "Ricorda Stato": mantiene lo stato salvato di ogni asta
var activeAuctions = savedAuctions.Where(a => a.IsActive && !a.IsPaused).ToList(); // 🔥 FIX CRITICO: Avvia monitor anche per aste in pausa (IsActive=true)
var activeAuctions = savedAuctions.Where(a => a.IsActive).ToList();
var resumeAuctions = savedAuctions.Where(a => a.IsActive && !a.IsPaused).ToList();
var pausedAuctions = savedAuctions.Where(a => a.IsActive && a.IsPaused).ToList();
if (activeAuctions.Any()) if (activeAuctions.Any())
{ {
Console.WriteLine($"[STARTUP] Resuming monitoring for {activeAuctions.Count} active auctions"); Console.WriteLine($"[STARTUP] Starting monitor for {activeAuctions.Count} active auctions ({resumeAuctions.Count} active, {pausedAuctions.Count} paused)");
monitor.Start(); monitor.Start();
appState.IsMonitoringActive = true; appState.IsMonitoringActive = true;
appState.AddLog($"[STARTUP] Ripristinato stato salvato: {activeAuctions.Count} aste attive");
if (pausedAuctions.Any())
{
appState.AddLog($"[STARTUP] Ripristinate {resumeAuctions.Count} aste attive + {pausedAuctions.Count} in pausa (polling attivo)");
}
else
{
appState.AddLog($"[STARTUP] Ripristinato stato salvato: {resumeAuctions.Count} aste attive");
}
} }
else else
{ {
@@ -365,7 +461,7 @@ using (var scope = app.Services.CreateScope())
} }
else else
{ {
// Modalità "Default": applica DefaultStartAuctionsOnLoad a tutte le aste // Modalità "Default": applica DefaultStartAuctionsOnLoad a tutte le aste
switch (settings.DefaultStartAuctionsOnLoad) switch (settings.DefaultStartAuctionsOnLoad)
{ {
case "Active": case "Active":
@@ -424,18 +520,79 @@ using (var scope = app.Services.CreateScope())
if (!app.Environment.IsDevelopment()) if (!app.Environment.IsDevelopment())
{ {
app.UseExceptionHandler("/Error"); app.UseExceptionHandler("/Error");
// Abilita HSTS solo se HTTPS è attivo
if (enableHttps)
{
app.UseHsts(); app.UseHsts();
}
} }
else else
{ {
app.UseDeveloperExceptionPage(); app.UseDeveloperExceptionPage();
} }
app.UseHttpsRedirection(); // Abilita HTTPS redirection solo se HTTPS è configurato
if (enableHttps)
{
app.UseHttpsRedirection();
}
app.UseStaticFiles(); app.UseStaticFiles();
app.UseRouting(); app.UseRouting();
// ============================================
// MIDDLEWARE AUTENTICAZIONE E AUTORIZZAZIONE
// ============================================
app.UseAuthentication();
app.UseAuthorization();
app.MapRazorPages(); // ? AGGIUNTO: abilita Razor Pages (Login, Logout)
app.MapBlazorHub(); app.MapBlazorHub();
app.MapFallbackToPage("/_Host"); app.MapFallbackToPage("/_Host");
// ?????????????????????????????????????????????????????????????????
// TIMER PULIZIA MEMORIA PERIODICA
// ?????????????????????????????????????????????????????????????????
// Timer per pulizia periodica della memoria (ogni 5 minuti)
var memoryCleanupTimer = new System.Threading.Timer(async _ =>
{
try
{
using var scope = app.Services.CreateScope();
var appState = scope.ServiceProvider.GetRequiredService<ApplicationStateService>();
var htmlCache = scope.ServiceProvider.GetRequiredService<HtmlCacheService>();
// Pulisci cache HTML scaduta
htmlCache.CleanExpiredCache();
// Compatta dati aste completate
appState.CleanupCompletedAuctions();
// Forza garbage collection leggera
GC.Collect(1, GCCollectionMode.Optimized, false);
// Log statistiche memoria
var stats = appState.GetMemoryStats();
var memoryMB = GC.GetTotalMemory(false) / 1024.0 / 1024.0;
Console.WriteLine($"[MEMORY] Cleanup: {stats.AuctionsCount} aste, " +
$"{stats.TotalBidHistoryEntries} bid history, " +
$"{stats.TotalRecentBidsEntries} recent bids, " +
$"{stats.GlobalLogEntries} global log, " +
$"RAM: {memoryMB:F1}MB");
}
catch (Exception ex)
{
Console.WriteLine($"[MEMORY ERROR] Cleanup failed: {ex.Message}");
}
}, null, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(5));
// Assicura che il timer venga disposto quando l'app si chiude
app.Lifetime.ApplicationStopping.Register(() =>
{
Console.WriteLine("[SHUTDOWN] Disposing memory cleanup timer...");
memoryCleanupTimer.Dispose();
});
app.Run(); app.Run();
+424
View File
@@ -0,0 +1,424 @@
# ?? AutoBidder - Sistema Automatizzato Gestione Aste Bidoo
[![Version](https://img.shields.io/badge/version-1.2.0-blue.svg)](CHANGELOG.md)
[![.NET](https://img.shields.io/badge/.NET-8.0-purple.svg)](https://dotnet.microsoft.com/)
[![Blazor](https://img.shields.io/badge/Blazor-Server-orange.svg)](https://dotnet.microsoft.com/apps/aspnet/web-apps/blazor)
[![Docker](https://img.shields.io/badge/Docker-Ready-brightgreen.svg)](Dockerfile)
[![Security](https://img.shields.io/badge/Security-Identity-green.svg)](SECURITY.md)
[![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
Sistema Blazor .NET 8 per il monitoraggio e la partecipazione automatica alle aste Bidoo, con **autenticazione sicura** per deploy Tailscale.
---
## ?? Quick Start
### ?? NUOVO v1.2.0: Configurazione Sicurezza
```bash
# 1. Copia e configura credenziali
cp .env.example .env
nano .env # Imposta ADMIN_PASSWORD
# 2. Avvia container
docker-compose up -d
# 3. Primo login
# Browser: http://localhost:5000/login
# Username: admin
# Password: (valore ADMIN_PASSWORD)
```
### Docker (CONSIGLIATO)
```bash
# Pull ultima versione da Gitea
docker pull gitea.encke-hake.ts.net/alby96/autobidder:1.2.0
# Avvia container CON AUTENTICAZIONE
docker run -d \
--name autobidder \
-p 5000:8080 \
-e ADMIN_USERNAME=admin \
-e ADMIN_PASSWORD="TuaPasswordSicura123!" \
-v /path/to/data:/app/Data \
gitea.encke-hake.ts.net/alby96/autobidder:1.2.0
# Accedi a http://localhost:5000/login
```
### Docker Compose
```bash
# 1. Configura .env
cp .env.example .env
# Imposta ADMIN_PASSWORD in .env
# 2. Avvia stack
docker-compose up -d
```
### Development Locale
```bash
# Imposta password admin
export ADMIN_PASSWORD="DevPassword123!"
# Avvia applicazione
dotnet run --project AutoBidder.csproj
# Accedi a http://localhost:8080/login
```
---
## ?? Versione Corrente: `1.2.0`
**Release:** 2025-01-18
**Tipo:** MINOR (feature sicurezza + autenticazione)
### ?? Novità v1.2.0 - SICUREZZA
- ?? **Sistema autenticazione completo**
- Login username/password con ASP.NET Core Identity
- Protezione brute-force (lockout 15 min dopo 5 tentativi)
- Cookie sicuri (HttpOnly, SameSite)
- Password policy forte (min 12 caratteri)
- ??? **Protezione route**
- Tutte le pagine richiedono autenticazione
- Redirect automatico a `/login`
- Gestione sessioni sicura
- ?? **Configurazione utente admin**
- Username/password via environment variables
- Password temporanea se non configurata (?? da cambiare!)
- Database Identity SQLite persistente
**[?? Changelog Completo](CHANGELOG.md)** | **[?? Guida Sicurezza](SECURITY.md)**
---
## ? Caratteristiche Principali
### ?? Monitoraggio Aste Real-time
- Rilevamento automatico nuove aste
- Tracking partecipanti e offerte
- Calcolo valore prodotto e probabilità vittoria
- Notifiche eventi importanti
### ?? Sistema Offerte Automatico
- Strategie configurabili per tipo prodotto
- Gestione budget e limiti
- Auto-bid su aste promettenti
- Prevenzione overbid
### ?? Statistiche Avanzate
- Database PostgreSQL per analytics
- Storico aste chiuse
- Analisi performance prodotti
- Dashboard interattive
### ?? Gestione Sessione Sicura
- Login automatico Bidoo
- Session persistence
- Cookie management
- Auto-refresh token
### ?? Persistenza Dati
- SQLite per dati operativi
- PostgreSQL per statistiche
- Backup automatici
- Export/Import configurazioni
---
## ?? Struttura Progetto
```
AutoBidder/
??? ?? AutoBidder.csproj # Configurazione progetto (.NET 8)
??? ?? Dockerfile # Container image definition
??? ?? docker-compose.yml # Stack completo (app + PostgreSQL)
??? ?? Program.cs # Entry point applicazione
??? ?? CHANGELOG.md # Storico versioni
??? ?? VERSIONING.md # Sistema versionamento
?
??? ?? Pages/ # Blazor Pages
? ??? Index.razor # Dashboard principale
? ??? FreeBids.razor # Gestione crediti gratuiti
? ??? Settings.razor # Configurazione
? ??? Statistics.razor # Analytics avanzate
?
??? ?? Services/ # Business Logic
? ??? AuctionMonitor.cs # Core monitoring engine
? ??? BidooApiClient.cs # API client Bidoo
? ??? SessionManager.cs # Gestione autenticazione
? ??? StatsService.cs # Analytics service
? ??? DatabaseService.cs # Data persistence
?
??? ?? Data/ # Database Contexts
? ??? StatisticsContext.cs # SQLite context
? ??? PostgresStatsContext.cs # PostgreSQL context
?
??? ?? Models/ # Data Models
? ??? AuctionInfo.cs
? ??? BidderInfo.cs
? ??? ProductStat.cs
? ??? ...
?
??? ?? Properties/PublishProfiles/ # Profili pubblicazione
??? GiteaRegistry.pubxml # Gitea Container Registry
```
---
## ?? Configurazione
### Variabili Ambiente
```bash
# Ambiente ASP.NET
ASPNETCORE_ENVIRONMENT=Production
ASPNETCORE_URLS=http://+:8080
# Kestrel
Kestrel__EnableHttps=false
# Database
Database__SQLitePath=/app/Data/autobidder.db
Database__PostgreSQLConnection=Host=postgres;Database=autobidder_stats;Username=autobidder;Password=***
# Bidoo
Bidoo__Username=your_email@example.com
Bidoo__Password=your_password
Bidoo__MonitorInterval=5000
# Backup
Backup__Enabled=true
Backup__IntervalHours=24
```
### Porte
| Ambiente | Host | Container | Protocollo |
|----------|------|-----------|------------|
| Development | 5001 | 5001 | HTTPS |
| Docker | 5000 | 8080 | HTTP |
---
## ?? Documentazione
### Per Utenti
- [?? Guida Rapida](docs/QUICK_START.md)
- [?? Configurazione](docs/CONFIGURATION.md)
- [? FAQ](docs/FAQ.md)
- [?? Troubleshooting](docs/TROUBLESHOOTING.md)
### Per Sviluppatori
- [?? Docker Publishing Guide](DOCKER_PUBLISH_GUIDE.md)
- [?? Sistema Versionamento](VERSIONING.md)
- [?? Setup Ambiente Dev](docs/DEVELOPMENT.md)
- [??? Architettura](docs/ARCHITECTURE.md)
### Changelog & Release
- [?? CHANGELOG](CHANGELOG.md) - Storico modifiche
- [?? VERSIONING](VERSIONING.md) - Sistema versioning
- [?? Bump Version Script](bump-version.ps1) - Automazione
---
## ?? Deployment
### Docker (Production)
```bash
# Pull versione specifica (CONSIGLIATO)
docker pull gitea.encke-hake.ts.net/alby96/autobidder:1.1.0
# Avvia con docker-compose
docker-compose up -d
# Verifica logs
docker-compose logs -f autobidder
# Accedi
http://localhost:5000
```
### Unraid
1. **Aggiungi Container**
- Repository: `gitea.encke-hake.ts.net/alby96/autobidder:1.1.0`
- Port: `5000` (host) ? `8080` (container)
- Volume: `/mnt/user/appdata/autobidder/data` ? `/app/Data`
- Volume: `/mnt/user/appdata/autobidder/logs` ? `/app/logs`
2. **Variabili Ambiente**
- `ASPNETCORE_ENVIRONMENT=Production`
- `Bidoo__Username=email@example.com`
- `Bidoo__Password=***`
3. **Avvia Container**
**[?? Guida Completa Deployment](deployment/README.md)**
---
## ?? Aggiornamento Versione
### Da v1.0.0 a v1.1.0
**Breaking Changes:**
- ?? Porta container: `5000` ? `8080`
- ?? HTTPS disabilitato di default
**Aggiornamento:**
```bash
# 1. Stop container vecchio
docker stop autobidder
docker rm autobidder
# 2. Pull nuova versione
docker pull gitea.encke-hake.ts.net/alby96/autobidder:1.1.0
# 3. Aggiorna port mapping (5000:8080 invece di 5000:5000)
docker run -d \
--name autobidder \
-p 5000:8080 \
-v /data:/app/Data \
gitea.encke-hake.ts.net/alby96/autobidder:1.1.0
# 4. Verifica
docker logs -f autobidder
```
**[?? Note di Migrazione Complete](CHANGELOG.md#note-di-migrazione)**
---
## ??? Sviluppo
### Build Locale
```bash
# Restore dipendenze
dotnet restore
# Build
dotnet build
# Run
dotnet run
# Test
dotnet test
```
### Build Docker
```bash
# Build immagine
docker build -t autobidder:dev .
# Test locale
docker run -p 5000:8080 autobidder:dev
```
### Pubblicazione su Gitea
```bash
# Da Visual Studio
# Tasto destro ? Pubblica ? GiteaRegistry
# Da CLI
dotnet publish /p:PublishProfile=GiteaRegistry
```
### Incremento Versione
```powershell
# Bug fix (1.1.0 ? 1.1.1)
.\bump-version.ps1 -Type patch
# Nuova feature (1.1.0 ? 1.2.0)
.\bump-version.ps1 -Type minor
# Breaking change (1.1.0 ? 2.0.0)
.\bump-version.ps1 -Type major
```
---
## ?? Contribuire
1. Fork del repository
2. Crea feature branch (`git checkout -b feature/amazing-feature`)
3. Commit modifiche (`git commit -m 'feat: add amazing feature'`)
4. Push al branch (`git push origin feature/amazing-feature`)
5. Apri Pull Request
**[?? Contribution Guidelines](CONTRIBUTING.md)**
---
## ?? License
Questo progetto è rilasciato sotto licenza MIT. Vedi [LICENSE](LICENSE) per dettagli.
---
## ?? Supporto
- ?? [Segnala Bug](https://gitea.encke-hake.ts.net/Alby96/Mimante/issues)
- ?? [Richiedi Feature](https://gitea.encke-hake.ts.net/Alby96/Mimante/issues)
- ?? Email: [support@example.com](mailto:support@example.com)
---
## ?? Ringraziamenti
- [.NET Team](https://dotnet.microsoft.com/) per .NET 8 e Blazor
- [PostgreSQL](https://www.postgresql.org/) per il database
- Community open source
---
## ?? Roadmap
### v1.2.0 (Q1 2025)
- [ ] Notifiche email per aste vinte
- [ ] Export statistiche CSV/Excel
- [ ] Dashboard mobile-responsive
### v1.3.0 (Q2 2025)
- [ ] API REST pubblica
- [ ] Integrazione webhook
- [ ] Multi-utente support
### v2.0.0 (Q3 2025)
- [ ] Architettura microservizi
- [ ] Supporto multi-piattaforma aste
- [ ] Machine learning per predizioni
**[?? Roadmap Completa](docs/ROADMAP.md)**
---
<div align="center">
**Fatto con ?? usando .NET 8 e Blazor**
[?? Home](https://gitea.encke-hake.ts.net/Alby96/Mimante) •
[?? Docs](docs/) •
[?? Changelog](CHANGELOG.md) •
[?? Issues](https://gitea.encke-hake.ts.net/Alby96/Mimante/issues)
**? Se ti piace il progetto, lascia una stella!**
</div>
+183 -5
View File
@@ -1,4 +1,5 @@
using AutoBidder.Models; using AutoBidder.Models;
using AutoBidder.Utilities;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
@@ -52,6 +53,67 @@ namespace AutoBidder.Services
} }
} }
/// <summary>
/// Ottiene riferimento diretto alla lista per lettura veloce (NO COPY).
/// ATTENZIONE: Non modificare la lista, usare solo per lettura!
/// </summary>
public List<AuctionInfo> GetAuctionsDirectRef()
{
return _auctions; // Accesso diretto senza lock per velocità
}
/// <summary>
/// Ottiene riferimento diretto al log per lettura veloce (NO COPY).
/// </summary>
public List<LogEntry> GetLogDirectRef()
{
return _globalLog;
}
/// <summary>
/// Imposta l'asta selezionata SENZA notificare eventi async.
/// Usare per risposta UI immediata.
/// </summary>
public void SetSelectedAuctionDirect(AuctionInfo? auction)
{
_selectedAuction = auction;
}
/// <summary>
/// Ottiene la lista originale delle aste per il salvataggio.
/// ATTENZIONE: Usare solo per persistenza, non per iterazione durante modifiche!
/// </summary>
public List<AuctionInfo> GetAuctionsForPersistence()
{
lock (_lock)
{
return _auctions;
}
}
/// <summary>
/// Forza il salvataggio delle aste correnti su disco.
/// </summary>
public void PersistAuctions()
{
lock (_lock)
{
AutoBidder.Utilities.PersistenceManager.SaveAuctions(_auctions);
}
}
/// <summary>
/// Ottiene l'asta modificabile per ID.
/// IMPORTANTE: Dopo modifiche, chiamare PersistAuctions() per salvare!
/// </summary>
public AuctionInfo? GetAuctionById(string auctionId)
{
lock (_lock)
{
return _auctions.FirstOrDefault(a => a.AuctionId == auctionId);
}
}
public AuctionInfo? SelectedAuction public AuctionInfo? SelectedAuction
{ {
get get
@@ -112,6 +174,47 @@ namespace AutoBidder.Services
} }
} }
// === STATO AUCTION BROWSER ===
private int _browserCategoryIndex = 0;
private string _browserSearchQuery = "";
public int BrowserCategoryIndex
{
get
{
lock (_lock)
{
return _browserCategoryIndex;
}
}
set
{
lock (_lock)
{
_browserCategoryIndex = value;
}
}
}
public string BrowserSearchQuery
{
get
{
lock (_lock)
{
return _browserSearchQuery;
}
}
set
{
lock (_lock)
{
_browserSearchQuery = value;
}
}
}
// === METODI GESTIONE ASTE === // === METODI GESTIONE ASTE ===
public void SetAuctions(List<AuctionInfo> auctions) public void SetAuctions(List<AuctionInfo> auctions)
@@ -200,15 +303,16 @@ namespace AutoBidder.Services
{ {
_globalLog.Add(entry); _globalLog.Add(entry);
// Mantieni solo gli ultimi 1000 log // Mantieni solo gli ultimi 500 log (ridotto da 1000 per RAM)
if (_globalLog.Count > 1000) if (_globalLog.Count > 500)
{ {
_globalLog.RemoveRange(0, _globalLog.Count - 1000); _globalLog.RemoveRange(0, _globalLog.Count - 500);
_globalLog.TrimExcess();
} }
} }
_ = NotifyLogAddedAsync(message); // RIMOSSO: NotifyStateChangedAsync qui causava troppi re-render
_ = NotifyStateChangedAsync(); // I log vengono visualizzati al prossimo refresh naturale
} }
public void ClearLog() public void ClearLog()
@@ -314,6 +418,80 @@ namespace AutoBidder.Services
{ {
_ = NotifyStateChangedAsync(); _ = NotifyStateChangedAsync();
} }
// ???????????????????????????????????????????????????????????????????
// GESTIONE MEMORIA
// ???????????????????????????????????????????????????????????????????
/// <summary>
/// Compatta i dati di tutte le aste per ridurre il consumo RAM
/// </summary>
public void CompactAllAuctions()
{
lock (_lock)
{
foreach (var auction in _auctions)
{
try
{
auction.CompactData();
}
catch { /* Ignora errori */ }
}
}
Console.WriteLine($"[AppState] Compattati dati di {_auctions.Count} aste");
}
/// <summary>
/// Pulisce i dati delle aste terminate dalla memoria
/// </summary>
public void CleanupCompletedAuctions()
{
lock (_lock)
{
foreach (var auction in _auctions.Where(a => !a.IsActive))
{
try
{
// Per le aste terminate, mantieni solo dati essenziali
auction.CompactData(maxBidHistory: 20, maxRecentBids: 10, maxLogLines: 50);
}
catch { }
}
}
}
/// <summary>
/// Ritorna statistiche sull'uso della memoria
/// </summary>
public MemoryStats GetMemoryStats()
{
lock (_lock)
{
return new MemoryStats
{
AuctionsCount = _auctions.Count,
ActiveAuctionsCount = _auctions.Count(a => a.IsActive),
TotalBidHistoryEntries = _auctions.Sum(a => a.BidHistory?.Count ?? 0),
TotalRecentBidsEntries = _auctions.Sum(a => a.RecentBids?.Count ?? 0),
TotalLogEntries = _auctions.Sum(a => a.AuctionLog?.Count ?? 0),
GlobalLogEntries = _globalLog.Count
};
}
}
}
/// <summary>
/// Statistiche memoria per debug
/// </summary>
public class MemoryStats
{
public int AuctionsCount { get; set; }
public int ActiveAuctionsCount { get; set; }
public int TotalBidHistoryEntries { get; set; }
public int TotalRecentBidsEntries { get; set; }
public int TotalLogEntries { get; set; }
public int GlobalLogEntries { get; set; }
} }
/// <summary> /// <summary>
File diff suppressed because it is too large Load Diff
+544
View File
@@ -0,0 +1,544 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AutoBidder.Models;
using AutoBidder.Utilities;
namespace AutoBidder.Services
{
/// <summary>
/// Servizio per strategie avanzate di puntata.
/// Implementa: adaptive latency, jitter, dynamic offset, heat metric,
/// competition detection, soft retreat, probabilistic bidding, opponent profiling.
/// </summary>
public class BidStrategyService
{
private readonly Random _random = new();
private int _sessionTotalBids = 0;
private DateTime _sessionStartedAt = DateTime.UtcNow;
/// <summary>
/// Aggiorna heat metric per un'asta
/// </summary>
public void UpdateHeatMetric(AuctionInfo auction, AppSettings settings, string currentUsername = "")
{
if (!settings.CompetitionDetectionEnabled) return;
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var windowStart = now - settings.CompetitionWindowSeconds;
// Conta bidder unici nella finestra temporale (escludo me stesso)
var recentBids = auction.RecentBids
.Where(b => b.Timestamp >= windowStart)
.Where(b => !b.Username.Equals(currentUsername, StringComparison.OrdinalIgnoreCase))
.ToList();
auction.ActiveBiddersCount = recentBids
.Select(b => b.Username)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Count();
// Conta collisioni (puntate nello stesso secondo)
var bidsBySecond = recentBids
.GroupBy(b => b.Timestamp)
.Where(g => g.Count() > 1)
.Count();
auction.CollisionCount = bidsBySecond;
// Calcola heat metric (0-100)
// Fattori: bidder attivi (40%), frequenza puntate (30%), collisioni (30%)
int bidderScore = Math.Min(auction.ActiveBiddersCount * 15, 40); // Max 40 punti
int frequencyScore = Math.Min(recentBids.Count * 3, 30); // Max 30 punti
int collisionScore = Math.Min(auction.CollisionCount * 10, 30); // Max 30 punti
auction.HeatMetric = bidderScore + frequencyScore + collisionScore;
// Identifica bidder aggressivi e situazioni di duello
if (settings.OpponentProfilingEnabled)
{
UpdateAggressiveBidders(auction, settings, currentUsername);
DetectDuelSituation(auction, settings, currentUsername);
}
}
/// <summary>
/// Identifica e tracca bidder aggressivi (basato su ultime N puntate, esclude utente corrente)
/// </summary>
private void UpdateAggressiveBidders(AuctionInfo auction, AppSettings settings, string currentUsername)
{
// ?? FIX: Usa finestra scorrevole di ultime N puntate
var windowSize = settings.AggressiveBidderWindowSize > 0 ? settings.AggressiveBidderWindowSize : 30;
var recentWindow = auction.RecentBids
.Take(windowSize)
.ToList();
var bidCounts = recentWindow
.GroupBy(b => b.Username, StringComparer.OrdinalIgnoreCase)
.Select(g => new { Username = g.Key, Count = g.Count(), Percentage = (double)g.Count() / recentWindow.Count * 100 })
.ToList();
auction.AggressiveBidders.Clear();
foreach (var bidder in bidCounts)
{
// ?? FIX: NON aggiungere l'utente corrente come aggressivo!
if (bidder.Username.Equals(currentUsername, StringComparison.OrdinalIgnoreCase))
continue;
// ?? FIX: Soglia più permissiva - usa percentuale invece di conteggio assoluto
// Un bidder è "aggressivo" se ha più del 40% delle puntate nella finestra (configurabile)
var percentageThreshold = settings.AggressiveBidderPercentageThreshold > 0 ? settings.AggressiveBidderPercentageThreshold : 40.0;
if (bidder.Percentage >= percentageThreshold || bidder.Count >= settings.AggressiveBidderThreshold)
{
auction.AggressiveBidders.Add(bidder.Username);
}
}
}
/// <summary>
/// Rileva situazione di "duello" (solo 2 bidder attivi che si contendono l'asta)
/// In questa situazione bisogna essere pronti perché se uno si ritira l'altro vince
/// </summary>
private void DetectDuelSituation(AuctionInfo auction, AppSettings settings, string currentUsername)
{
var windowSize = settings.DuelDetectionWindowSize > 0 ? settings.DuelDetectionWindowSize : 20;
var recentWindow = auction.RecentBids.Take(windowSize).ToList();
if (recentWindow.Count < 6) // Serve un minimo di puntate per rilevare un pattern
{
auction.IsDuelSituation = false;
auction.DuelOpponent = null;
return;
}
var bidders = recentWindow
.GroupBy(b => b.Username, StringComparer.OrdinalIgnoreCase)
.Select(g => new { Username = g.Key, Count = g.Count(), Percentage = (double)g.Count() / recentWindow.Count * 100 })
.OrderByDescending(b => b.Count)
.ToList();
// Duello: esattamente 2 bidder dominanti che coprono almeno l'80% delle puntate
if (bidders.Count >= 2)
{
var top2Percentage = bidders.Take(2).Sum(b => b.Percentage);
if (top2Percentage >= 80 && bidders.Count <= 3)
{
auction.IsDuelSituation = true;
// Trova l'avversario (chi NON sono io)
var opponent = bidders.FirstOrDefault(b =>
!b.Username.Equals(currentUsername, StringComparison.OrdinalIgnoreCase));
auction.DuelOpponent = opponent?.Username;
// Calcola chi sta dominando
var myStats = bidders.FirstOrDefault(b =>
b.Username.Equals(currentUsername, StringComparison.OrdinalIgnoreCase));
auction.DuelAdvantage = myStats != null && opponent != null
? myStats.Percentage - opponent.Percentage
: 0;
}
else
{
auction.IsDuelSituation = false;
auction.DuelOpponent = null;
auction.DuelAdvantage = 0;
}
}
else
{
auction.IsDuelSituation = false;
auction.DuelOpponent = null;
}
}
/// <summary>
/// Verifica se è il caso di puntare considerando tutte le strategie
/// </summary>
public BidDecision ShouldPlaceBid(AuctionInfo auction, AuctionState state, AppSettings settings, string currentUsername)
{
var decision = new BidDecision { ShouldBid = true };
// Se le strategie avanzate sono disabilitate per questa asta, salta tutto
if (auction.AdvancedStrategiesEnabled == false)
{
return decision;
}
// ? RIMOSSO: Entry Point - Era sbagliato!
// I limiti MinPrice/MaxPrice impostati dall'utente sono RIGIDI.
// Se l'utente imposta MaxPrice=2€, vuole puntare FINO A 2€, non fino al 70%!
// I controlli MinPrice/MaxPrice sono già gestiti in AuctionMonitor.ShouldBid()
// L'Entry Point può essere usato SOLO per calcolare limiti CONSIGLIATI, non per bloccare.
// ?? 1. ANTI-BOT - Rileva pattern bot (timing identico)
if (settings.AntiBotDetectionEnabled && !string.IsNullOrEmpty(state.LastBidder))
{
var botCheck = DetectBotPattern(auction, state.LastBidder, currentUsername);
if (botCheck.IsBot)
{
decision.ShouldBid = false;
decision.Reason = $"Anti-bot: {state.LastBidder} pattern sospetto (var={botCheck.TimingVarianceMs:F0}ms)";
return decision;
}
}
// ?? 2. USER EXHAUSTION - Sfrutta utenti stanchi (info solo, non blocca)
if (settings.UserExhaustionEnabled && !string.IsNullOrEmpty(state.LastBidder))
{
var exhaustionCheck = CheckUserExhaustion(auction, state.LastBidder, currentUsername);
// Non blocchiamo, ma potremmo loggare per info
}
// 3. Verifica soft retreat
if (settings.SoftRetreatEnabled || (auction.SoftRetreatEnabledOverride ?? settings.SoftRetreatEnabled))
{
if (auction.IsInSoftRetreat)
{
var retreatEnd = auction.LastSoftRetreatAt?.AddSeconds(settings.SoftRetreatDurationSeconds);
if (retreatEnd > DateTime.UtcNow)
{
decision.ShouldBid = false;
decision.Reason = $"Soft retreat attivo (termina tra {(retreatEnd.Value - DateTime.UtcNow).TotalSeconds:F0}s)";
return decision;
}
else
{
// Fine soft retreat
auction.IsInSoftRetreat = false;
auction.ConsecutiveCollisions = 0;
}
}
// Verifica se attivare soft retreat
if (auction.ConsecutiveCollisions >= settings.SoftRetreatAfterCollisions)
{
auction.IsInSoftRetreat = true;
auction.LastSoftRetreatAt = DateTime.UtcNow;
decision.ShouldBid = false;
decision.Reason = $"Soft retreat attivato dopo {auction.ConsecutiveCollisions} collisioni";
return decision;
}
}
// 2. Verifica competition threshold
if (settings.CompetitionDetectionEnabled)
{
if (auction.ActiveBiddersCount >= settings.CompetitionThreshold)
{
// Controlla se l'ultimo bidder sono io - se sì, posso continuare
var lastBid = auction.RecentBids.OrderByDescending(b => b.Timestamp).FirstOrDefault();
if (lastBid != null && !lastBid.Username.Equals(currentUsername, StringComparison.OrdinalIgnoreCase))
{
if (settings.AutoPauseHotAuctions && auction.HeatMetric >= settings.HeatThresholdForPause)
{
decision.ShouldBid = false;
decision.Reason = $"Asta troppo calda (heat={auction.HeatMetric}%, bidder={auction.ActiveBiddersCount})";
return decision;
}
}
}
}
// 3. Verifica opponent profiling
if (settings.OpponentProfilingEnabled && auction.AggressiveBidders.Count > 0)
{
if (settings.AggressiveBidderAction == "Avoid")
{
decision.ShouldBid = false;
decision.Reason = $"Bidder aggressivi rilevati: {string.Join(", ", auction.AggressiveBidders.Take(3))}";
return decision;
}
}
// 4. Probabilistic bidding
if (settings.ProbabilisticBiddingEnabled)
{
var probability = CalculateBidProbability(auction, settings);
var roll = _random.NextDouble();
if (roll > probability)
{
decision.ShouldBid = false;
decision.Reason = $"Skip probabilistico (p={probability:P0}, roll={roll:P0})";
return decision;
}
}
// 5. Bankroll manager
if (settings.BankrollManagerEnabled)
{
var bankrollCheck = CheckBankrollLimits(auction, settings);
if (!bankrollCheck.CanBid)
{
decision.ShouldBid = false;
decision.Reason = bankrollCheck.Reason;
return decision;
}
}
// ? RIMOSSO: DetectLastSecondSniper - causava falsi positivi
// In un duello, TUTTI i bidder hanno pattern regolari (ogni reset del timer)
// Questa strategia bloccava puntate legittime e faceva perdere aste
// ?? 7. STRATEGIA: Price Momentum (con soglia più alta)
// Se il prezzo sta salendo TROPPO velocemente, pausa
var priceVelocity = CalculatePriceVelocity(auction);
if (priceVelocity > 0.10) // +10 centesimi/secondo = MOLTO veloce
{
decision.ShouldBid = false;
decision.Reason = $"Prezzo sale troppo veloce ({priceVelocity:F3}€/s)";
return decision;
}
return decision;
}
/// <summary>
/// Calcola la velocità di crescita del prezzo (€/secondo)
/// </summary>
private double CalculatePriceVelocity(AuctionInfo auction)
{
if (auction.RecentBids.Count < 5) return 0;
var recentBids = auction.RecentBids.Take(10).ToList();
if (recentBids.Count < 2) return 0;
var first = recentBids.Last();
var last = recentBids.First();
var timeDiffSeconds = last.Timestamp - first.Timestamp;
if (timeDiffSeconds <= 0) return 0;
var priceDiff = last.Price - first.Price;
return (double)priceDiff / timeDiffSeconds;
}
/// <summary>
/// Rileva pattern bot analizzando i delta timing degli ultimi bid
/// </summary>
private (bool IsBot, double TimingVarianceMs) DetectBotPattern(AuctionInfo auction, string? lastBidder, string currentUsername)
{
if (string.IsNullOrEmpty(lastBidder) || lastBidder.Equals(currentUsername, StringComparison.OrdinalIgnoreCase))
return (false, 999);
// Ottieni gli ultimi 3+ bid di questo utente
var userBids = auction.RecentBids
.Where(b => b.Username.Equals(lastBidder, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(b => b.Timestamp)
.Take(4)
.ToList();
if (userBids.Count < 3)
return (false, 999);
// Calcola i delta tra bid consecutivi
var deltas = new List<long>();
for (int i = 0; i < userBids.Count - 1; i++)
{
deltas.Add(userBids[i].Timestamp - userBids[i + 1].Timestamp);
}
if (deltas.Count < 2)
return (false, 999);
// Calcola varianza dei delta
var avg = deltas.Average();
var variance = deltas.Sum(d => Math.Pow(d - avg, 2)) / deltas.Count;
var stdDev = Math.Sqrt(variance) * 1000; // Converti in ms
// Se la varianza è < 50ms, probabilmente è un bot
return (stdDev < 50, stdDev);
}
/// <summary>
/// Verifica se un utente è esausto (molte puntate, può mollare)
/// </summary>
private (bool ShouldExploit, string Reason) CheckUserExhaustion(AuctionInfo auction, string? lastBidder, string currentUsername)
{
if (string.IsNullOrEmpty(lastBidder) || lastBidder.Equals(currentUsername, StringComparison.OrdinalIgnoreCase))
return (false, "");
// Verifica se l'utente è un "heavy user" (>50 puntate totali)
if (auction.BidderStats.TryGetValue(lastBidder, out var stats))
{
if (stats.BidCount > 50)
{
// Se ci sono pochi altri bidder attivi, può essere un buon momento
var activeBidders = auction.BidderStats.Values.Count(b => b.BidCount > 5);
if (activeBidders <= 3)
{
return (true, $"{lastBidder} ha {stats.BidCount} puntate, potrebbe mollare");
}
}
}
return (false, "");
}
/// <summary>
/// Calcola probabilità di puntata basata su competizione e ROI
/// </summary>
private double CalculateBidProbability(AuctionInfo auction, AppSettings settings)
{
var probability = settings.BaseBidProbability;
// Riduci probabilità per ogni bidder attivo oltre la soglia
var extraBidders = Math.Max(0, auction.ActiveBiddersCount - settings.CompetitionThreshold);
probability -= extraBidders * settings.ProbabilityReductionPerBidder;
// Riduci per heat metric alto
if (auction.HeatMetric > 70)
{
probability -= 0.1;
}
// Aumenta se abbiamo un buon ROI potenziale
if (auction.CalculatedValue?.Savings > 0)
{
probability += 0.1;
}
return Math.Clamp(probability, 0.1, 1.0);
}
/// <summary>
/// Verifica limiti bankroll
/// </summary>
private BankrollCheckResult CheckBankrollLimits(AuctionInfo auction, AppSettings settings)
{
var result = new BankrollCheckResult { CanBid = true };
// Limite puntate per asta
var maxPerAuction = auction.MaxBidsOverride ?? settings.MaxBidsPerAuction;
if (maxPerAuction > 0 && auction.SessionBidCount >= maxPerAuction)
{
result.CanBid = false;
result.Reason = $"Limite puntate per asta raggiunto ({auction.SessionBidCount}/{maxPerAuction})";
return result;
}
// Limite puntate per sessione
if (settings.MaxBidsPerSession > 0 && _sessionTotalBids >= settings.MaxBidsPerSession)
{
result.CanBid = false;
result.Reason = $"Limite puntate per sessione raggiunto ({_sessionTotalBids}/{settings.MaxBidsPerSession})";
return result;
}
// Budget giornaliero
if (settings.DailyBudgetEuro > 0)
{
var spent = _sessionTotalBids * settings.AverageBidCostEuro;
if (spent >= settings.DailyBudgetEuro)
{
result.CanBid = false;
result.Reason = $"Budget giornaliero esaurito (€{spent:F2}/€{settings.DailyBudgetEuro:F2})";
return result;
}
}
return result;
}
/// <summary>
/// Registra una puntata effettuata (per tracking)
/// </summary>
public void RecordBidAttempt(AuctionInfo auction, bool success, bool collision = false)
{
auction.SessionBidCount++;
_sessionTotalBids++;
if (success)
{
auction.SuccessfulBidCount++;
auction.ConsecutiveCollisions = 0;
}
else
{
auction.FailedBidCount++;
}
if (collision)
{
auction.CollisionCount++;
auction.ConsecutiveCollisions++;
}
}
/// <summary>
/// Registra un timer scaduto
/// </summary>
public void RecordTimerExpired(AuctionInfo auction)
{
auction.TimerExpiredCount++;
auction.ConsecutiveCollisions++; // Conta come "mancato"
}
/// <summary>
/// Reset contatori sessione
/// </summary>
public void ResetSession()
{
_sessionTotalBids = 0;
_sessionStartedAt = DateTime.UtcNow;
}
/// <summary>
/// Ottiene statistiche sessione corrente
/// </summary>
public SessionStats GetSessionStats()
{
return new SessionStats
{
TotalBids = _sessionTotalBids,
SessionDuration = DateTime.UtcNow - _sessionStartedAt
};
}
}
/// <summary>
/// Risultato calcolo timing puntata
/// </summary>
public class BidTimingResult
{
public int BaseOffsetMs { get; set; }
public int LatencyCompensationMs { get; set; }
public int DynamicAdjustmentMs { get; set; }
public int JitterMs { get; set; }
public int FinalOffsetMs { get; set; }
public bool ShouldBid { get; set; }
}
/// <summary>
/// Decisione se puntare
/// </summary>
public class BidDecision
{
public bool ShouldBid { get; set; }
public string? Reason { get; set; }
}
/// <summary>
/// Risultato verifica bankroll
/// </summary>
public class BankrollCheckResult
{
public bool CanBid { get; set; }
public string? Reason { get; set; }
}
/// <summary>
/// Statistiche sessione
/// </summary>
public class SessionStats
{
public int TotalBids { get; set; }
public TimeSpan SessionDuration { get; set; }
}
}
+742
View File
@@ -0,0 +1,742 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using AutoBidder.Models;
namespace AutoBidder.Services
{
/// <summary>
/// Servizio per navigare le aste pubbliche di Bidoo senza autenticazione
/// Permette di esplorare le categorie e visualizzare le aste disponibili
/// </summary>
public class BidooBrowserService
{
private readonly HttpClient _httpClient;
private readonly List<BidooCategoryInfo> _cachedCategories = new();
private DateTime _categoriesCachedAt = DateTime.MinValue;
private readonly TimeSpan _categoryCacheExpiry = TimeSpan.FromMinutes(30);
public BidooBrowserService()
{
var handler = new HttpClientHandler
{
UseCookies = false,
AutomaticDecompression = System.Net.DecompressionMethods.All
};
_httpClient = new HttpClient(handler)
{
Timeout = TimeSpan.FromSeconds(15)
};
}
/// <summary>
/// Aggiunge headers browser-like per evitare blocchi
/// </summary>
private void AddBrowserHeaders(HttpRequestMessage request, string? referer = null)
{
request.Headers.Add("User-Agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36");
request.Headers.Add("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8");
request.Headers.Add("Accept-Language", "it-IT,it;q=0.9,en-US;q=0.8,en;q=0.7");
request.Headers.Add("Accept-Encoding", "gzip, deflate, br");
request.Headers.Add("Cache-Control", "no-cache");
request.Headers.Add("Pragma", "no-cache");
if (!string.IsNullOrEmpty(referer))
{
request.Headers.Add("Referer", referer);
}
}
/// <summary>
/// Ottiene la lista delle categorie disponibili (con cache)
/// </summary>
public async Task<List<BidooCategoryInfo>> GetCategoriesAsync(bool forceRefresh = false, CancellationToken cancellationToken = default)
{
// Controlla cache
if (!forceRefresh && _cachedCategories.Count > 0 && DateTime.UtcNow - _categoriesCachedAt < _categoryCacheExpiry)
{
return _cachedCategories.ToList();
}
var categories = new List<BidooCategoryInfo>();
try
{
var request = new HttpRequestMessage(HttpMethod.Get, "https://it.bidoo.com/");
AddBrowserHeaders(request);
var response = await _httpClient.SendAsync(request, cancellationToken);
response.EnsureSuccessStatusCode();
var html = await response.Content.ReadAsStringAsync(cancellationToken);
// Aggiungi categorie speciali prima
categories.Add(new BidooCategoryInfo { TabId = 3, TagId = 0, DisplayName = "Tutte le aste", Slug = "", IsSpecialCategory = true, Icon = "bi-grid-3x3-gap" });
categories.Add(new BidooCategoryInfo { TabId = 1, TagId = 0, DisplayName = "Aste di Puntate", Slug = "", IsSpecialCategory = true, Icon = "bi-coin" });
categories.Add(new BidooCategoryInfo { TabId = 5, TagId = 0, DisplayName = "Aste Manuali", Slug = "", IsSpecialCategory = true, Icon = "bi-hand-index" });
// Parse categorie dal CategoryMenu
// Pattern: javascript:selectBids(4, true, false, 6); con data-tag="6" e testo "Buoni"
var categoryPattern = new Regex(
@"<a\s+href=""\s*javascript:selectBids\(4,\s*true,\s*false,\s*(\d+)\);\s*""\s+data-tab=""4""\s+data-slug=""([^""]*)""\s+data-tag=""(\d+)""><span[^>]*>([^<]+)</span></a>",
RegexOptions.IgnoreCase | RegexOptions.Singleline);
var matches = categoryPattern.Matches(html);
foreach (Match match in matches)
{
if (match.Success && match.Groups.Count >= 5)
{
int.TryParse(match.Groups[1].Value, out int tagId1);
var slug = match.Groups[2].Value.Trim();
int.TryParse(match.Groups[3].Value, out int tagId2);
var name = match.Groups[4].Value.Trim();
// Usa tagId1 o tagId2 (dovrebbero essere uguali)
var tagId = tagId1 > 0 ? tagId1 : tagId2;
if (tagId > 0 && !string.IsNullOrWhiteSpace(name))
{
categories.Add(new BidooCategoryInfo
{
TabId = 4,
TagId = tagId,
Slug = slug,
DisplayName = name,
IsSpecialCategory = false
});
}
}
}
// Se non abbiamo trovato categorie dal parsing, usa lista predefinita
if (categories.Count <= 3)
{
categories.AddRange(GetDefaultCategories());
}
// Aggiorna cache
_cachedCategories.Clear();
_cachedCategories.AddRange(categories);
_categoriesCachedAt = DateTime.UtcNow;
Console.WriteLine($"[BidooBrowser] Caricate {categories.Count} categorie");
}
catch (Exception ex)
{
Console.WriteLine($"[BidooBrowser] Errore caricamento categorie: {ex.Message}");
// Fallback a categorie predefinite
if (_cachedCategories.Count == 0)
{
categories.AddRange(GetDefaultCategories());
_cachedCategories.AddRange(categories);
}
else
{
return _cachedCategories.ToList();
}
}
return categories;
}
/// <summary>
/// Categorie predefinite come fallback
/// </summary>
private static List<BidooCategoryInfo> GetDefaultCategories()
{
return new List<BidooCategoryInfo>
{
new() { TabId = 4, TagId = 6, DisplayName = "Buoni", Slug = "buoni" },
new() { TabId = 4, TagId = 5, DisplayName = "Smartphone", Slug = "smartphone" },
new() { TabId = 4, TagId = 7, DisplayName = "Apple", Slug = "apple" },
new() { TabId = 4, TagId = 13, DisplayName = "Bellezza", Slug = "bellezza" },
new() { TabId = 4, TagId = 8, DisplayName = "Cucina", Slug = "cucina" },
new() { TabId = 4, TagId = 18, DisplayName = "Casa & Giardino", Slug = "casa_e_giardino" },
new() { TabId = 4, TagId = 11, DisplayName = "Elettrodomestici", Slug = "elettrodomestici" },
new() { TabId = 4, TagId = 9, DisplayName = "Videogame", Slug = "videogame" },
new() { TabId = 4, TagId = 41, DisplayName = "Giocattoli", Slug = "giocattoli" },
new() { TabId = 4, TagId = 14, DisplayName = "Tablet e PC", Slug = "tablet-e-pc" },
new() { TabId = 4, TagId = 20, DisplayName = "Hobby", Slug = "hobby" },
new() { TabId = 4, TagId = 22, DisplayName = "Smartwatch", Slug = "smartwatch" },
new() { TabId = 4, TagId = 37, DisplayName = "Animali Domestici", Slug = "animali_domestici" },
new() { TabId = 4, TagId = 12, DisplayName = "Moda", Slug = "moda" },
new() { TabId = 4, TagId = 10, DisplayName = "Smart TV", Slug = "smart-tv" },
new() { TabId = 4, TagId = 21, DisplayName = "Fai da Te", Slug = "fai_da_te" },
new() { TabId = 4, TagId = 26, DisplayName = "Luxury", Slug = "luxury" },
new() { TabId = 4, TagId = 19, DisplayName = "Cuffie e Audio", Slug = "cuffie-e-audio" },
new() { TabId = 4, TagId = 23, DisplayName = "Back to school", Slug = "back-to-school" },
new() { TabId = 4, TagId = 38, DisplayName = "Prima Infanzia", Slug = "prima-infanzia" }
};
}
/// <summary>
/// Ottiene le aste di una categoria specifica
/// Bidoo usa un sistema AJAX per caricare le aste dinamicamente
/// </summary>
public async Task<List<BidooBrowserAuction>> GetAuctionsAsync(
BidooCategoryInfo category,
int page = 0,
CancellationToken cancellationToken = default)
{
var auctions = new List<BidooBrowserAuction>();
try
{
// Bidoo carica le aste tramite chiamata AJAX a index.php con parametri POST-like in query string
// Il pattern è: index.php?selectBids=1&tab=X&tag=Y&offset=Z
string url;
if (category.IsSpecialCategory)
{
// Categorie speciali: BIDS (1), ALL (3), MANUAL (5)
var tabValue = category.TabId;
url = $"https://it.bidoo.com/index.php?selectBids=1&tab={tabValue}&tag=0&offset={page * 20}";
}
else
{
// Categorie normali: tab=4 + tag specifico
url = $"https://it.bidoo.com/index.php?selectBids=1&tab=4&tag={category.TagId}&offset={page * 20}";
}
Console.WriteLine($"[BidooBrowser] Fetching category '{category.DisplayName}': {url}");
var request = new HttpRequestMessage(HttpMethod.Get, url);
AddBrowserHeaders(request, "https://it.bidoo.com/");
request.Headers.Add("X-Requested-With", "XMLHttpRequest");
var response = await _httpClient.SendAsync(request, cancellationToken);
response.EnsureSuccessStatusCode();
var html = await response.Content.ReadAsStringAsync(cancellationToken);
// Parse aste dall'HTML (fragment AJAX)
auctions = ParseAuctionsFromHtml(html);
Console.WriteLine($"[BidooBrowser] Trovate {auctions.Count} aste nella categoria {category.DisplayName}");
// ?? DEBUG: Verifica quante aste hanno IsCreditAuction = true
if (category.IsSpecialCategory && category.TabId == 1)
{
var creditCount = auctions.Count(a => a.IsCreditAuction);
Console.WriteLine($"[BidooBrowser] DEBUG Aste di Puntate: {creditCount}/{auctions.Count} hanno IsCreditAuction=true");
// Log primi 3 nomi per debug
foreach (var a in auctions.Take(3))
{
Console.WriteLine($"[BidooBrowser] - {a.Name} (ID: {a.AuctionId}, IsCreditAuction: {a.IsCreditAuction})");
}
}
}
catch (Exception ex)
{
Console.WriteLine($"[BidooBrowser] Errore caricamento aste: {ex.Message}");
}
return auctions;
}
private static string GetTabName(int tabId)
{
return tabId switch
{
1 => "BIDS",
2 => "FAV",
3 => "ALL",
5 => "MANUAL",
_ => "ALL"
};
}
/// <summary>
/// Parsa le aste dall'HTML della pagina
/// </summary>
private List<BidooBrowserAuction> ParseAuctionsFromHtml(string html)
{
var auctions = new List<BidooBrowserAuction>();
try
{
// Pattern per estrarre i div delle aste
// <div id="divAsta85584421" class="..." data-id="85584421" data-url="27_Puntate_85584421" data-freq="8" ...>
var auctionDivPattern = new Regex(
@"<div\s+id=""divAsta(\d+)""[^>]*" +
@"data-id=""(\d+)""[^>]*" +
@"data-url=""([^""]+)""[^>]*" +
@"data-freq=""(\d+)""[^>]*" +
@"(?:data-credit=""(\d+)"")?[^>]*" +
@"(?:data-credit-value=""(\d+)"")?[^>]*" +
@"(?:data-id-product=""(\d+)"")?",
RegexOptions.IgnoreCase | RegexOptions.Singleline);
// Pattern alternativo più semplice per catturare attributi
var simplePattern = new Regex(
@"<div[^>]+id=""divAsta(\d+)""[^>]*>",
RegexOptions.IgnoreCase);
var divMatches = simplePattern.Matches(html);
foreach (Match divMatch in divMatches)
{
if (!divMatch.Success) continue;
var auctionId = divMatch.Groups[1].Value;
// Trova il blocco completo dell'asta
var startIndex = divMatch.Index;
var endPattern = @"<!--/ \.bid -->";
var endIndex = html.IndexOf(endPattern, startIndex);
if (endIndex < 0) endIndex = html.IndexOf("</div><!--", startIndex + 1000);
if (endIndex < 0) continue;
var auctionHtml = html.Substring(startIndex, Math.Min(endIndex - startIndex + 100, html.Length - startIndex));
var auction = ParseSingleAuction(auctionId, auctionHtml);
if (auction != null)
{
auctions.Add(auction);
}
}
}
catch (Exception ex)
{
Console.WriteLine($"[BidooBrowser] Errore parsing HTML: {ex.Message}");
}
return auctions;
}
/// <summary>
/// Parsa una singola asta dal suo blocco HTML
/// </summary>
private BidooBrowserAuction? ParseSingleAuction(string auctionId, string html)
{
try
{
var auction = new BidooBrowserAuction { AuctionId = auctionId };
// Estrai data-url
var urlMatch = Regex.Match(html, @"data-url=""([^""]+)""");
if (urlMatch.Success)
{
auction.Url = $"https://it.bidoo.com/auction.php?a={urlMatch.Groups[1].Value}";
}
// Estrai data-freq
var freqMatch = Regex.Match(html, @"data-freq=""(\d+)""");
if (freqMatch.Success && int.TryParse(freqMatch.Groups[1].Value, out int freq))
{
auction.TimerFrequency = freq;
}
// Estrai data-credit e data-credit-value
var creditMatch = Regex.Match(html, @"data-credit=""(\d+)""");
if (creditMatch.Success && creditMatch.Groups[1].Value == "1")
{
auction.IsCreditAuction = true;
}
var creditValueMatch = Regex.Match(html, @"data-credit-value=""(\d+)""");
if (creditValueMatch.Success && int.TryParse(creditValueMatch.Groups[1].Value, out int creditVal))
{
auction.CreditValue = creditVal;
}
// Estrai data-id-product
var productMatch = Regex.Match(html, @"data-id-product=""(\d+)""");
if (productMatch.Success && int.TryParse(productMatch.Groups[1].Value, out int productId))
{
auction.ProductId = productId;
}
// Estrai immagine
var imgMatch = Regex.Match(html, @"<img[^>]+class=""img_small[^""]*""[^>]+src=""([^""]+)""");
if (imgMatch.Success)
{
auction.ImageUrl = imgMatch.Groups[1].Value;
}
else
{
// Pattern alternativo
imgMatch = Regex.Match(html, @"src=""(https://[^""]+/products/[^""]+)""");
if (imgMatch.Success)
{
auction.ImageUrl = imgMatch.Groups[1].Value;
}
}
// Estrai nome prodotto
var nameMatch = Regex.Match(html, @"<a[^>]+class=""name[^""]*""[^>]*>([^<]+)</a>", RegexOptions.IgnoreCase);
if (nameMatch.Success)
{
var name = System.Net.WebUtility.HtmlDecode(nameMatch.Groups[1].Value.Trim());
// ?? FIX: Sostituisci entità HTML non standard con +
name = name
.Replace("&plus;", "+")
.Replace("&amp;plus;", "+")
.Replace("&amp;", "&"); // Decodifica & residui
auction.Name = name;
}
// Estrai prezzo compralo subito
var buyNowMatch = Regex.Match(html, @"buy-rapid-now[^>]*>[^<]*<i[^>]*></i>\s*([0-9,\.]+)\s*€", RegexOptions.IgnoreCase);
if (buyNowMatch.Success)
{
var priceStr = buyNowMatch.Groups[1].Value.Replace(",", ".").Trim();
if (decimal.TryParse(priceStr, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out decimal buyNow))
{
auction.BuyNowPrice = buyNow;
}
}
// Controlla se è manuale (bi-noauto)
auction.IsManualOnly = html.Contains("bi-noauto", StringComparison.OrdinalIgnoreCase);
// Prezzo e bidder verranno aggiornati dalla chiamata a data.php
auction.CurrentPrice = 0.01m;
auction.LastBidder = "";
auction.RemainingSeconds = auction.TimerFrequency;
return auction;
}
catch (Exception ex)
{
Console.WriteLine($"[BidooBrowser] Errore parsing asta {auctionId}: {ex.Message}");
return null;
}
}
/// <summary>
/// Aggiorna lo stato delle aste usando data.php con LISTID (polling multiplo)
/// Formato chiamata: data.php?LISTID=id1,id2,id3&chk=timestamp
/// Formato risposta: timestamp*(id;status;expiry;price;bidder;timer;countdown#id2;...)
/// </summary>
public async Task UpdateAuctionStatesAsync(List<BidooBrowserAuction> auctions, CancellationToken cancellationToken = default)
{
if (auctions.Count == 0) return;
try
{
// Costruisci la lista di ID per il polling (formato LISTID)
var auctionIds = string.Join(",", auctions.Select(a => a.AuctionId));
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
var url = $"https://it.bidoo.com/data.php?LISTID={auctionIds}&chk={timestamp}";
Console.WriteLine($"[BidooBrowser] Polling {auctions.Count} aste...");
var request = new HttpRequestMessage(HttpMethod.Get, url);
AddBrowserHeaders(request, "https://it.bidoo.com/");
request.Headers.Add("X-Requested-With", "XMLHttpRequest");
var response = await _httpClient.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode)
{
Console.WriteLine($"[BidooBrowser] Polling fallito: {response.StatusCode}");
return;
}
var responseText = await response.Content.ReadAsStringAsync(cancellationToken);
// Parse risposta formato LISTID
ParseListIdResponse(responseText, auctions);
foreach (var auction in auctions)
{
auction.LastUpdated = DateTime.UtcNow;
}
}
catch (Exception ex)
{
Console.WriteLine($"[BidooBrowser] Errore aggiornamento stati: {ex.Message}");
}
}
/// <summary>
/// Parsa la risposta di data.php formato LISTID
/// Formato: serverTimestamp*(id;status;expiry;price;;#id2;status2;...)
/// Esempio: 1769073106*(85559629;ON;1769082240;1;;#85559630;ON;1769082240;1;;)
/// Il timestamp del server viene usato come riferimento per calcolare il tempo rimanente
/// </summary>
private void ParseListIdResponse(string response, List<BidooBrowserAuction> auctions)
{
try
{
// Trova inizio dati dopo timestamp*
var starIndex = response.IndexOf('*');
if (starIndex == -1)
{
Console.WriteLine("[BidooBrowser] Risposta non valida: manca '*'");
return;
}
// Estrai il timestamp del server (prima di *)
var serverTimestampStr = response.Substring(0, starIndex);
long serverTimestamp = 0;
long.TryParse(serverTimestampStr, out serverTimestamp);
var mainData = response.Substring(starIndex + 1);
// Rimuovi parentesi se presenti
if (mainData.StartsWith("(") && mainData.EndsWith(")"))
{
mainData = mainData.Substring(1, mainData.Length - 2);
}
// Split per ogni asta (separatore #)
var auctionEntries = mainData.Split('#', StringSplitOptions.RemoveEmptyEntries);
int updatedCount = 0;
foreach (var entry in auctionEntries)
{
// Formato: id;status;expiry;price;; (bidder e timer possono essere vuoti)
var fields = entry.Split(';');
if (fields.Length < 4) continue;
var id = fields[0].Trim();
var status = fields[1].Trim(); // ON/OFF
var expiryStr = fields[2].Trim(); // timestamp scadenza (stesso formato del server)
var priceStr = fields[3].Trim(); // prezzo (centesimi)
var bidder = fields.Length > 4 ? fields[4].Trim() : ""; // ultimo bidder (può essere vuoto)
var auction = auctions.FirstOrDefault(a => a.AuctionId == id);
if (auction == null) continue;
// Aggiorna prezzo (è in centesimi, convertire in euro)
if (int.TryParse(priceStr, out int priceCents))
{
auction.CurrentPrice = priceCents / 100m;
}
// Aggiorna bidder solo se non vuoto
if (!string.IsNullOrEmpty(bidder))
{
auction.LastBidder = bidder;
}
// Calcola tempo rimanente usando il timestamp del server come riferimento
if (long.TryParse(expiryStr, out long expiryTimestamp) && serverTimestamp > 0)
{
// Il tempo rimanente è: expiry - serverTime (entrambi nello stesso formato)
var remainingSeconds = expiryTimestamp - serverTimestamp;
auction.RemainingSeconds = remainingSeconds > 0 ? (int)remainingSeconds : 0;
}
else if (status == "ON")
{
// Se non riusciamo a calcolare, usa il timer frequency come fallback
if (auction.RemainingSeconds <= 0)
{
auction.RemainingSeconds = auction.TimerFrequency;
}
}
// Status: ON = attiva in countdown, OFF = terminata/in pausa
auction.IsActive = status == "ON";
auction.IsSold = status != "ON" && auction.RemainingSeconds <= 0;
updatedCount++;
}
Console.WriteLine($"[BidooBrowser] Aggiornate {updatedCount} aste su {auctionEntries.Length}");
}
catch (Exception ex)
{
Console.WriteLine($"[BidooBrowser] Errore parsing LISTID response: {ex.Message}");
}
}
/// <summary>
/// Converte countdown string in secondi
/// Formati: "7m", "1h 16m", "00:08", vuoto (usa timer frequency)
/// </summary>
private int ParseCountdown(string countdown, int defaultSeconds)
{
if (string.IsNullOrWhiteSpace(countdown))
{
return defaultSeconds;
}
try
{
// Formato ore e minuti: "1h 16m"
var hourMatch = Regex.Match(countdown, @"(\d+)h");
var minMatch = Regex.Match(countdown, @"(\d+)m");
int totalSeconds = 0;
if (hourMatch.Success && int.TryParse(hourMatch.Groups[1].Value, out int hours))
{
totalSeconds += hours * 3600;
}
if (minMatch.Success && int.TryParse(minMatch.Groups[1].Value, out int mins))
{
totalSeconds += mins * 60;
}
if (totalSeconds > 0)
{
return totalSeconds;
}
// Formato "00:08" (mm:ss o ss)
if (countdown.Contains(":"))
{
var parts = countdown.Split(':');
if (parts.Length == 2 &&
int.TryParse(parts[0], out int p1) &&
int.TryParse(parts[1], out int p2))
{
return p1 * 60 + p2;
}
}
// Solo numero = secondi
if (int.TryParse(countdown, out int secs))
{
return secs;
}
}
catch { }
return defaultSeconds;
}
/// <summary>
/// Carica nuove aste usando get_auction_updates.php (simula scrolling infinito)
/// Questa API restituisce aste che non sono ancora state caricate
/// </summary>
public async Task<List<BidooBrowserAuction>> GetMoreAuctionsAsync(
BidooCategoryInfo category,
List<string> existingAuctionIds,
CancellationToken cancellationToken = default)
{
var newAuctions = new List<BidooBrowserAuction>();
try
{
var existingIdsSet = existingAuctionIds.ToHashSet();
// Prepara la chiamata POST a get_auction_updates.php
var url = "https://it.bidoo.com/get_auction_updates.php";
// Costruisci il body della richiesta
var viewIds = string.Join(",", existingAuctionIds);
var tabValue = category.IsSpecialCategory ? category.TabId : 4;
var tagValue = category.IsSpecialCategory ? 0 : category.TagId;
var formContent = new FormUrlEncodedContent(new[]
{
new KeyValuePair<string, string>("prefetch", "true"),
new KeyValuePair<string, string>("view", viewIds),
new KeyValuePair<string, string>("tab", tabValue.ToString()),
new KeyValuePair<string, string>("tag", tagValue.ToString())
});
var request = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = formContent
};
AddBrowserHeaders(request, "https://it.bidoo.com/");
request.Headers.Add("X-Requested-With", "XMLHttpRequest");
Console.WriteLine($"[BidooBrowser] Fetching more auctions with {existingAuctionIds.Count} existing IDs...");
var response = await _httpClient.SendAsync(request, cancellationToken);
response.EnsureSuccessStatusCode();
var responseText = await response.Content.ReadAsStringAsync(cancellationToken);
// Parse la risposta JSON
// Formato: {"gc":[],"int":[],"list":[id1,id2,...],"items":["<html>","<html>",...]}
newAuctions = ParseGetAuctionUpdatesResponse(responseText, existingIdsSet);
Console.WriteLine($"[BidooBrowser] Trovate {newAuctions.Count} nuove aste");
}
catch (Exception ex)
{
Console.WriteLine($"[BidooBrowser] Errore caricamento nuove aste: {ex.Message}");
}
return newAuctions;
}
/// <summary>
/// Parsa la risposta di get_auction_updates.php
/// </summary>
private List<BidooBrowserAuction> ParseGetAuctionUpdatesResponse(string json, HashSet<string> existingIds)
{
var auctions = new List<BidooBrowserAuction>();
try
{
// Parse JSON manuale per estrarre items[]
// Cerchiamo "items":["...","..."]
var itemsMatch = Regex.Match(json, @"""items"":\s*\[(.*?)\](?=,""|\})", RegexOptions.Singleline);
if (!itemsMatch.Success)
{
Console.WriteLine("[BidooBrowser] Nessun items trovato nella risposta");
return auctions;
}
var itemsContent = itemsMatch.Groups[1].Value;
// Gli items sono stringhe HTML escaped, dobbiamo parsarle
// Ogni item è una stringa JSON che contiene HTML
var htmlPattern = new Regex(@"""((?:[^""\\]|\\.)*?)""", RegexOptions.Singleline);
var htmlMatches = htmlPattern.Matches(itemsContent);
foreach (Match htmlMatch in htmlMatches)
{
if (!htmlMatch.Success) continue;
// Unescape la stringa JSON
var escapedHtml = htmlMatch.Groups[1].Value;
var html = UnescapeJsonString(escapedHtml);
// Estrai l'ID dell'asta
var idMatch = Regex.Match(html, @"id=""divAsta(\d+)""");
if (!idMatch.Success) continue;
var auctionId = idMatch.Groups[1].Value;
// Salta se già esiste
if (existingIds.Contains(auctionId)) continue;
// Parsa l'asta dall'HTML
var auction = ParseSingleAuction(auctionId, html);
if (auction != null)
{
auctions.Add(auction);
}
}
}
catch (Exception ex)
{
Console.WriteLine($"[BidooBrowser] Errore parsing get_auction_updates response: {ex.Message}");
}
return auctions;
}
/// <summary>
/// Unescape di una stringa JSON
/// </summary>
private static string UnescapeJsonString(string escaped)
{
return escaped
.Replace("\\/", "/")
.Replace("\\n", "\n")
.Replace("\\r", "\r")
.Replace("\\t", "\t")
.Replace("\\\"", "\"")
.Replace("\\\\", "\\");
}
}
}
File diff suppressed because it is too large Load Diff
+22 -3
View File
@@ -28,6 +28,7 @@ namespace AutoBidder.Services
private readonly int _maxConcurrentRequests; private readonly int _maxConcurrentRequests;
private readonly TimeSpan _cacheExpiration; private readonly TimeSpan _cacheExpiration;
private readonly int _maxRetries; private readonly int _maxRetries;
private readonly int _maxCacheEntries;
// Logging callback // Logging callback
public Action<string>? OnLog { get; set; } public Action<string>? OnLog { get; set; }
@@ -36,12 +37,14 @@ namespace AutoBidder.Services
int maxConcurrentRequests = 3, int maxConcurrentRequests = 3,
int requestsPerSecond = 5, int requestsPerSecond = 5,
TimeSpan? cacheExpiration = null, TimeSpan? cacheExpiration = null,
int maxRetries = 2) int maxRetries = 2,
int maxCacheEntries = 50)
{ {
_maxConcurrentRequests = maxConcurrentRequests; _maxConcurrentRequests = maxConcurrentRequests;
_minRequestDelay = TimeSpan.FromMilliseconds(1000.0 / requestsPerSecond); _minRequestDelay = TimeSpan.FromMilliseconds(1000.0 / requestsPerSecond);
_cacheExpiration = cacheExpiration ?? TimeSpan.FromMinutes(5); _cacheExpiration = cacheExpiration ?? TimeSpan.FromMinutes(3); // Ridotto da 5 a 3 minuti
_maxRetries = maxRetries; _maxRetries = maxRetries;
_maxCacheEntries = maxCacheEntries;
_rateLimiter = new SemaphoreSlim(maxConcurrentRequests, maxConcurrentRequests); _rateLimiter = new SemaphoreSlim(maxConcurrentRequests, maxConcurrentRequests);
_httpClient.Timeout = TimeSpan.FromSeconds(15); _httpClient.Timeout = TimeSpan.FromSeconds(15);
@@ -191,10 +194,26 @@ namespace AutoBidder.Services
} }
/// <summary> /// <summary>
/// Salva HTML in cache /// Salva HTML in cache con limite dimensione
/// </summary> /// </summary>
private void SaveToCache(string url, string html) private void SaveToCache(string url, string html)
{ {
// Limita dimensione cache per evitare memory leak
if (_cache.Count >= _maxCacheEntries)
{
// Rimuovi le entry più vecchie
var oldestEntries = _cache
.OrderBy(kvp => kvp.Value.Timestamp)
.Take(_cache.Count - _maxCacheEntries + 10) // Rimuovi 10 extra per evitare chiamate frequenti
.Select(kvp => kvp.Key)
.ToList();
foreach (var key in oldestEntries)
{
_cache.TryRemove(key, out _);
}
}
_cache[url] = new CachedHtml _cache[url] = new CachedHtml
{ {
Html = html, Html = html,
@@ -0,0 +1,352 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using AutoBidder.Models;
namespace AutoBidder.Services
{
/// <summary>
/// Servizio per calcolare statistiche aggregate per prodotto e generare limiti consigliati.
/// L'algoritmo analizza le aste storiche per determinare i parametri ottimali.
/// </summary>
public class ProductStatisticsService
{
private readonly DatabaseService _db;
public ProductStatisticsService(DatabaseService db)
{
_db = db;
}
/// <summary>
/// Genera una chiave univoca normalizzata per raggruppare prodotti simili.
/// Rimuove varianti, numeri di serie, colori ecc.
/// </summary>
public static string GenerateProductKey(string productName)
{
if (string.IsNullOrWhiteSpace(productName))
return "unknown";
var normalized = productName.ToLowerInvariant().Trim();
// Rimuovi contenuto tra parentesi (varianti, colori, capacità)
normalized = Regex.Replace(normalized, @"\([^)]*\)", "");
normalized = Regex.Replace(normalized, @"\[[^\]]*\]", "");
// Rimuovi colori comuni
var colors = new[] { "nero", "bianco", "grigio", "rosso", "blu", "verde", "oro", "argento",
"black", "white", "gray", "red", "blue", "green", "gold", "silver",
"space gray", "midnight", "starlight" };
foreach (var color in colors)
{
normalized = Regex.Replace(normalized, $@"\b{color}\b", "", RegexOptions.IgnoreCase);
}
// Rimuovi capacità storage (64gb, 128gb, 256gb, ecc.)
normalized = Regex.Replace(normalized, @"\b\d+\s*(gb|tb|mb)\b", "", RegexOptions.IgnoreCase);
// Rimuovi numeri di serie e codici prodotto
normalized = Regex.Replace(normalized, @"\b[A-Z]{2,}\d{3,}\b", "", RegexOptions.IgnoreCase);
// Normalizza spazi e caratteri speciali
normalized = Regex.Replace(normalized, @"[^a-z0-9\s]", " ");
normalized = Regex.Replace(normalized, @"\s+", "_");
normalized = normalized.Trim('_');
// Limita lunghezza
if (normalized.Length > 50)
normalized = normalized.Substring(0, 50);
return string.IsNullOrEmpty(normalized) ? "unknown" : normalized;
}
/// <summary>
/// Aggiorna le statistiche aggregate per un prodotto dopo una nuova asta completata
/// </summary>
public async Task UpdateProductStatisticsAsync(string productKey, string productName)
{
try
{
// Ottieni tutti i risultati per questo prodotto
var results = await _db.GetAuctionResultsByProductAsync(productKey, 500);
if (results.Count == 0)
{
Console.WriteLine($"[ProductStats] No results found for product: {productKey}");
return;
}
// Calcola statistiche aggregate
var wonResults = results.Where(r => r.Won).ToList();
var lostResults = results.Where(r => !r.Won).ToList();
var stats = new ProductStatisticsRecord
{
ProductKey = productKey,
ProductName = productName,
TotalAuctions = results.Count,
WonAuctions = wonResults.Count,
LostAuctions = lostResults.Count
};
// Statistiche prezzo (usa aste vinte per calcolare i target)
if (wonResults.Any())
{
stats.AvgFinalPrice = wonResults.Average(r => r.FinalPrice);
stats.MinFinalPrice = wonResults.Min(r => r.FinalPrice);
stats.MaxFinalPrice = wonResults.Max(r => r.FinalPrice);
stats.MedianFinalPrice = CalculateMedian(wonResults.Select(r => r.FinalPrice).ToList());
}
else if (results.Any())
{
stats.AvgFinalPrice = results.Average(r => r.FinalPrice);
stats.MinFinalPrice = results.Min(r => r.FinalPrice);
stats.MaxFinalPrice = results.Max(r => r.FinalPrice);
stats.MedianFinalPrice = CalculateMedian(results.Select(r => r.FinalPrice).ToList());
}
// Statistiche puntate (usa WinnerBidsUsed se disponibile, altrimenti BidsUsed)
var bidsData = wonResults
.Where(r => r.WinnerBidsUsed.HasValue || r.BidsUsed > 0)
.Select(r => r.WinnerBidsUsed ?? r.BidsUsed)
.ToList();
if (bidsData.Any())
{
stats.AvgBidsToWin = bidsData.Select(b => (double)b).Average();
stats.MinBidsToWin = bidsData.Min();
stats.MaxBidsToWin = bidsData.Max();
}
// Statistiche reset
var resetData = wonResults.Where(r => r.TotalResets.HasValue).Select(r => r.TotalResets!.Value).ToList();
if (resetData.Any())
{
stats.AvgResets = resetData.Select(r => (double)r).Average();
stats.MinResets = resetData.Min();
stats.MaxResets = resetData.Max();
}
// Calcola limiti consigliati
var limits = CalculateRecommendedLimits(results);
stats.RecommendedMinPrice = limits.MinPrice;
stats.RecommendedMaxPrice = limits.MaxPrice;
stats.RecommendedMinResets = limits.MinResets;
stats.RecommendedMaxResets = limits.MaxResets;
stats.RecommendedMaxBids = limits.MaxBids;
// Calcola statistiche per fascia oraria
var hourlyStats = CalculateHourlyStats(results);
stats.HourlyStatsJson = JsonSerializer.Serialize(hourlyStats);
// Salva nel database
await _db.UpsertProductStatisticsAsync(stats);
Console.WriteLine($"[ProductStats] Updated stats for {productKey}: {stats.TotalAuctions} auctions, WinRate={stats.WinRate:F1}%");
}
catch (Exception ex)
{
Console.WriteLine($"[ProductStats ERROR] Failed to update stats for {productKey}: {ex.Message}");
}
}
/// <summary>
/// Calcola i limiti consigliati basandosi sui dati storici
/// </summary>
public RecommendedLimits CalculateRecommendedLimits(List<AutoBidder.Models.AuctionResultExtended> results)
{
var limits = new RecommendedLimits
{
SampleSize = results.Count
};
if (results.Count < 3)
{
limits.ConfidenceScore = 0;
return limits;
}
var wonResults = results.Where(r => r.Won).ToList();
if (wonResults.Count == 0)
{
// Nessuna vittoria: usa tutti i risultati con margine conservativo
limits.ConfidenceScore = 10;
limits.MinPrice = results.Min(r => r.FinalPrice) * 0.8;
limits.MaxPrice = results.Max(r => r.FinalPrice) * 1.2;
return limits;
}
// Calcola percentili sui prezzi delle aste vinte
var prices = wonResults.Select(r => r.FinalPrice).OrderBy(p => p).ToList();
limits.MinPrice = CalculatePercentile(prices, 10); // 10° percentile - entrare presto
limits.MaxPrice = CalculatePercentile(prices, 90); // 90° percentile - limite sicuro
// Calcola limiti reset
var resets = wonResults.Where(r => r.TotalResets.HasValue).Select(r => r.TotalResets!.Value).ToList();
if (resets.Any())
{
var avgResets = resets.Average();
var stdDev = CalculateStandardDeviation(resets.Select(r => (double)r).ToList());
limits.MinResets = Math.Max(0, (int)(avgResets - stdDev)); // Media - 1 stddev
limits.MaxResets = (int)(avgResets + stdDev); // Media + 1 stddev
}
// Calcola limiti puntate
var bids = wonResults
.Where(r => r.WinnerBidsUsed.HasValue || r.BidsUsed > 0)
.Select(r => r.WinnerBidsUsed ?? r.BidsUsed)
.OrderBy(b => b)
.ToList();
if (bids.Any())
{
// 90° percentile + 10% buffer
limits.MaxBids = (int)(CalculatePercentile(bids.Select(b => (double)b).ToList(), 90) * 1.1);
}
// Trova la fascia oraria migliore
var hourlyWins = wonResults
.Where(r => r.ClosedAtHour.HasValue)
.GroupBy(r => r.ClosedAtHour!.Value)
.Select(g => new { Hour = g.Key, Wins = g.Count() })
.OrderByDescending(x => x.Wins)
.FirstOrDefault();
if (hourlyWins != null)
{
limits.BestHourToPlay = hourlyWins.Hour;
}
// Win rate
limits.AverageWinRate = results.Count > 0 ? (double)wonResults.Count / results.Count * 100 : 0;
// Confidence score basato sul sample size
limits.ConfidenceScore = results.Count switch
{
>= 50 => 95,
>= 30 => 85,
>= 20 => 70,
>= 10 => 50,
>= 5 => 30,
_ => 15
};
return limits;
}
/// <summary>
/// Calcola statistiche aggregate per ogni fascia oraria
/// </summary>
private List<HourlyStats> CalculateHourlyStats(List<AutoBidder.Models.AuctionResultExtended> results)
{
var stats = new List<HourlyStats>();
var grouped = results
.Where(r => r.ClosedAtHour.HasValue)
.GroupBy(r => r.ClosedAtHour!.Value);
foreach (var group in grouped)
{
var hourResults = group.ToList();
var wonInHour = hourResults.Where(r => r.Won).ToList();
stats.Add(new HourlyStats
{
Hour = group.Key,
TotalAuctions = hourResults.Count,
WonAuctions = wonInHour.Count,
AvgFinalPrice = hourResults.Any() ? hourResults.Average(r => r.FinalPrice) : 0,
AvgBidsUsed = hourResults.Any() ? hourResults.Average(r => r.BidsUsed) : 0
});
}
return stats.OrderBy(s => s.Hour).ToList();
}
/// <summary>
/// Ottiene le statistiche per un prodotto
/// </summary>
public async Task<ProductStatisticsRecord?> GetProductStatisticsAsync(string productKey)
{
return await _db.GetProductStatisticsAsync(productKey);
}
/// <summary>
/// Ottiene tutti i prodotti con statistiche
/// </summary>
public async Task<List<ProductStatisticsRecord>> GetAllProductStatisticsAsync()
{
return await _db.GetAllProductStatisticsAsync();
}
/// <summary>
/// Ottiene i limiti consigliati per un prodotto
/// </summary>
public async Task<RecommendedLimits?> GetRecommendedLimitsAsync(string productKey)
{
var stats = await _db.GetProductStatisticsAsync(productKey);
if (stats == null)
return null;
return new RecommendedLimits
{
MinPrice = stats.RecommendedMinPrice ?? 0,
MaxPrice = stats.RecommendedMaxPrice ?? 0,
MinResets = stats.RecommendedMinResets ?? 0,
MaxResets = stats.RecommendedMaxResets ?? 0,
MaxBids = stats.RecommendedMaxBids ?? 0,
ConfidenceScore = stats.TotalAuctions switch
{
>= 50 => 95,
>= 30 => 85,
>= 20 => 70,
>= 10 => 50,
>= 5 => 30,
_ => 15
},
SampleSize = stats.TotalAuctions,
AverageWinRate = stats.WinRate
};
}
// Helpers per calcoli statistici
private static double CalculatePercentile(List<double> sortedData, int percentile)
{
if (sortedData.Count == 0) return 0;
if (sortedData.Count == 1) return sortedData[0];
double index = (percentile / 100.0) * (sortedData.Count - 1);
int lower = (int)Math.Floor(index);
int upper = (int)Math.Ceiling(index);
if (lower == upper) return sortedData[lower];
return sortedData[lower] + (sortedData[upper] - sortedData[lower]) * (index - lower);
}
private static double CalculateStandardDeviation(List<double> data)
{
if (data.Count < 2) return 0;
double avg = data.Average();
double sumSquares = data.Sum(d => Math.Pow(d - avg, 2));
return Math.Sqrt(sumSquares / (data.Count - 1));
}
private static double CalculateMedian(List<double> data)
{
if (data.Count == 0) return 0;
var sorted = data.OrderBy(x => x).ToList();
int mid = sorted.Count / 2;
return sorted.Count % 2 == 0
? (sorted[mid - 1] + sorted[mid]) / 2.0
: sorted[mid];
}
}
}
+280 -217
View File
@@ -2,64 +2,145 @@ using System;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using AutoBidder.Models; using AutoBidder.Models;
using AutoBidder.Data;
using System.Collections.Generic; using System.Collections.Generic;
using Microsoft.EntityFrameworkCore;
namespace AutoBidder.Services namespace AutoBidder.Services
{ {
/// <summary> /// <summary>
/// Servizio per calcolo e gestione statistiche avanzate /// Servizio per calcolo e gestione statistiche.
/// Usa PostgreSQL per statistiche persistenti e SQLite locale come fallback /// Usa esclusivamente il database SQLite interno gestito da DatabaseService.
/// Le statistiche sono disabilitate se il database non è disponibile.
/// </summary> /// </summary>
public class StatsService public class StatsService
{ {
private readonly DatabaseService _db; private readonly DatabaseService _db;
private readonly PostgresStatsContext? _postgresDb;
private readonly bool _postgresAvailable;
public StatsService(DatabaseService db, PostgresStatsContext? postgresDb = null) /// <summary>
/// Indica se le statistiche sono disponibili (database SQLite funzionante)
/// </summary>
public bool IsAvailable => _db.IsAvailable && _db.IsInitialized;
/// <summary>
/// Messaggio di errore se le statistiche non sono disponibili
/// </summary>
public string? ErrorMessage => !IsAvailable ? _db.InitializationError ?? "Database non disponibile" : null;
/// <summary>
/// Path del database SQLite
/// </summary>
public string DatabasePath => _db.DatabasePath;
private ProductStatisticsService? _productStatsService;
public StatsService(DatabaseService db)
{ {
_db = db; _db = db;
_postgresDb = postgresDb; _productStatsService = new ProductStatisticsService(db);
_postgresAvailable = false;
// Verifica disponibilità PostgreSQL // Log stato database SQLite
if (_postgresDb != null) Console.WriteLine($"[StatsService] Database available: {_db.IsAvailable}");
Console.WriteLine($"[StatsService] Database initialized: {_db.IsInitialized}");
if (!_db.IsAvailable)
{ {
try Console.WriteLine($"[StatsService] Database error: {_db.InitializationError}");
{
_postgresAvailable = _postgresDb.Database.CanConnect();
var status = _postgresAvailable ? "AVAILABLE" : "UNAVAILABLE";
Console.WriteLine($"[StatsService] PostgreSQL status: {status}");
}
catch (Exception ex)
{
Console.WriteLine($"[StatsService] PostgreSQL connection failed: {ex.Message}");
}
}
else
{
Console.WriteLine("[StatsService] PostgreSQL not configured - using SQLite only");
} }
} }
/// <summary> /// <summary>
/// Registra il completamento di un'asta (sia su PostgreSQL che SQLite) /// Registra il completamento di un'asta con tutti i dati per analytics
/// Include scraping HTML per ottenere le puntate del vincitore
/// </summary> /// </summary>
public async Task RecordAuctionCompletedAsync(AuctionInfo auction, bool won) public async Task RecordAuctionCompletedAsync(AuctionInfo auction, AuctionState state, bool won)
{ {
// Skip se database non disponibile
if (!IsAvailable)
{
Console.WriteLine("[StatsService] Skipping record - database not available");
return;
}
try try
{ {
Console.WriteLine($"[StatsService] ====== INIZIO SALVATAGGIO ASTA TERMINATA ======");
Console.WriteLine($"[StatsService] Asta: {auction.Name} (ID: {auction.AuctionId})");
Console.WriteLine($"[StatsService] Stato: {(won ? "VINTA" : "PERSA")}");
var today = DateTime.UtcNow.ToString("yyyy-MM-dd"); var today = DateTime.UtcNow.ToString("yyyy-MM-dd");
var bidsUsed = auction.BidsUsedOnThisAuction ?? 0; var bidsUsed = auction.BidsUsedOnThisAuction ?? 0;
var bidCost = auction.BidCost; var bidCost = auction.BidCost;
var moneySpent = bidsUsed * bidCost; var moneySpent = bidsUsed * bidCost;
var finalPrice = auction.LastState?.Price ?? 0; var finalPrice = state.Price;
var buyNowPrice = auction.BuyNowPrice; var buyNowPrice = auction.BuyNowPrice;
var shippingCost = auction.ShippingCost ?? 0; var shippingCost = auction.ShippingCost ?? 0;
// Dati aggiuntivi per analytics
var winnerUsername = state.LastBidder;
var totalResets = auction.ResetCount;
var productKey = ProductStatisticsService.GenerateProductKey(auction.Name);
Console.WriteLine($"[StatsService] Prezzo finale: €{finalPrice:F2}");
Console.WriteLine($"[StatsService] Puntate usate (utente): {bidsUsed}");
Console.WriteLine($"[StatsService] Vincitore: {winnerUsername ?? "N/A"}");
Console.WriteLine($"[StatsService] Reset totali: {totalResets}");
// ?? SCRAPING HTML: Ottieni puntate del vincitore dalla pagina dell'asta
int? winnerBidsUsed = null;
if (!string.IsNullOrEmpty(winnerUsername))
{
Console.WriteLine($"[StatsService] Avvio scraping HTML per ottenere puntate del vincitore...");
winnerBidsUsed = await ScrapeWinnerBidsFromAuctionPageAsync(auction.OriginalUrl);
// ? VALIDAZIONE: Verifica che i dati estratti siano ragionevoli
if (winnerBidsUsed.HasValue)
{
if (winnerBidsUsed.Value < 0)
{
Console.WriteLine($"[StatsService] ? VALIDAZIONE FALLITA: Puntate negative ({winnerBidsUsed.Value}) - Dato scartato");
winnerBidsUsed = null;
}
else if (winnerBidsUsed.Value > 50000)
{
Console.WriteLine($"[StatsService] ? VALIDAZIONE FALLITA: Puntate sospette ({winnerBidsUsed.Value} > 50000) - Dato scartato");
winnerBidsUsed = null;
}
else if (winnerBidsUsed.Value == 0 && totalResets > 10)
{
Console.WriteLine($"[StatsService] ? VALIDAZIONE FALLITA: 0 puntate con {totalResets} reset - Dato sospetto");
winnerBidsUsed = null;
}
else
{
Console.WriteLine($"[StatsService] ? Puntate vincitore estratte da HTML: {winnerBidsUsed.Value} (validazione OK)");
}
}
// Fallback se validazione fallita o scraping non riuscito
if (!winnerBidsUsed.HasValue)
{
Console.WriteLine($"[StatsService] ? Impossibile estrarre puntate valide del vincitore da HTML");
// Fallback: conta da RecentBids (meno affidabile)
if (auction.RecentBids != null)
{
winnerBidsUsed = auction.RecentBids
.Count(b => b.Username?.Equals(winnerUsername, StringComparison.OrdinalIgnoreCase) == true);
if (winnerBidsUsed.Value > 0)
{
Console.WriteLine($"[StatsService] Fallback: puntate vincitore da RecentBids: {winnerBidsUsed.Value}");
}
else
{
Console.WriteLine($"[StatsService] ? Fallback fallito: nessuna puntata trovata in RecentBids");
winnerBidsUsed = null;
}
}
}
}
double? totalCost = null; double? totalCost = null;
double? savings = null; double? savings = null;
@@ -67,9 +148,14 @@ namespace AutoBidder.Services
{ {
totalCost = finalPrice + moneySpent + shippingCost; totalCost = finalPrice + moneySpent + shippingCost;
savings = (buyNowPrice.Value + shippingCost) - totalCost.Value; savings = (buyNowPrice.Value + shippingCost) - totalCost.Value;
Console.WriteLine($"[StatsService] Costo totale: €{totalCost:F2}");
Console.WriteLine($"[StatsService] Risparmio: €{savings:F2}");
} }
// Salva su SQLite (sempre) Console.WriteLine($"[StatsService] Salvataggio nel database...");
// Salva risultato asta con tutti i campi
await _db.SaveAuctionResultAsync( await _db.SaveAuctionResultAsync(
auction.AuctionId, auction.AuctionId,
auction.Name, auction.Name,
@@ -79,9 +165,16 @@ namespace AutoBidder.Services
buyNowPrice, buyNowPrice,
shippingCost, shippingCost,
totalCost, totalCost,
savings savings,
winnerUsername,
totalResets,
winnerBidsUsed,
productKey
); );
Console.WriteLine($"[StatsService] ? Risultato asta salvato");
// Aggiorna statistiche giornaliere
await _db.SaveDailyStatAsync( await _db.SaveDailyStatAsync(
today, today,
bidsUsed, bidsUsed,
@@ -89,229 +182,178 @@ namespace AutoBidder.Services
won ? 1 : 0, won ? 1 : 0,
won ? 0 : 1, won ? 0 : 1,
savings ?? 0, savings ?? 0,
auction.LastState?.PollingLatencyMs state.PollingLatencyMs
); );
// Salva su PostgreSQL se disponibile Console.WriteLine($"[StatsService] ? Statistiche giornaliere aggiornate");
if (_postgresAvailable && _postgresDb != null)
// Aggiorna statistiche aggregate per prodotto
if (_productStatsService != null)
{ {
await SaveToPostgresAsync(auction, won, finalPrice, bidsUsed, totalCost, savings); Console.WriteLine($"[StatsService] Aggiornamento statistiche prodotto (key: {productKey})...");
await _productStatsService.UpdateProductStatisticsAsync(productKey, auction.Name);
Console.WriteLine($"[StatsService] ? Statistiche prodotto aggiornate");
} }
Console.WriteLine($"[StatsService] Recorded auction {auction.Name} - Won: {won}, Bids: {bidsUsed}, Savings: {savings:F2}€"); Console.WriteLine($"[StatsService] ====== SALVATAGGIO COMPLETATO ======");
} }
catch (Exception ex) catch (Exception ex)
{ {
Console.WriteLine($"[StatsService ERROR] Failed to record auction: {ex.Message}"); Console.WriteLine($"[StatsService ERROR] Failed to record auction: {ex.Message}");
Console.WriteLine($"[StatsService ERROR] Stack: {ex.StackTrace}");
} }
} }
/// <summary> /// <summary>
/// Salva asta conclusa su PostgreSQL /// Scarica l'HTML della pagina dell'asta e estrae le puntate del vincitore
/// </summary> /// </summary>
private async Task SaveToPostgresAsync(AuctionInfo auction, bool won, double finalPrice, int bidsUsed, double? totalCost, double? savings) private async Task<int?> ScrapeWinnerBidsFromAuctionPageAsync(string auctionUrl)
{ {
if (_postgresDb == null) return;
try try
{ {
var completedAuction = new CompletedAuction using var httpClient = new HttpClient();
// ? RIDOTTO: Timeout da 10s ? 5s per evitare rallentamenti
httpClient.Timeout = TimeSpan.FromSeconds(5);
// Headers browser-like per evitare rilevamento come bot
httpClient.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36");
httpClient.DefaultRequestHeaders.Add("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8");
httpClient.DefaultRequestHeaders.Add("Accept-Language", "it-IT,it;q=0.9,en-US;q=0.8,en;q=0.7");
Console.WriteLine($"[StatsService] Downloading HTML from: {auctionUrl} (timeout: 5s)");
var html = await httpClient.GetStringAsync(auctionUrl);
Console.WriteLine($"[StatsService] HTML scaricato ({html.Length} chars), parsing...");
// Usa il metodo esistente di ClosedAuctionsScraper per estrarre le puntate
var bidsUsed = ExtractBidsUsedFromHtml(html);
return bidsUsed;
}
catch (TaskCanceledException)
{ {
AuctionId = auction.AuctionId, Console.WriteLine($"[StatsService] ? Timeout durante download HTML (>5s) - URL: {auctionUrl}");
ProductName = auction.Name, return null;
FinalPrice = (decimal)finalPrice, }
BuyNowPrice = auction.BuyNowPrice.HasValue ? (decimal)auction.BuyNowPrice.Value : null, catch (HttpRequestException ex)
ShippingCost = auction.ShippingCost.HasValue ? (decimal)auction.ShippingCost.Value : null, {
TotalBids = auction.LastState?.MyBidsCount ?? bidsUsed, // Usa MyBidsCount se disponibile Console.WriteLine($"[StatsService] ? Errore HTTP durante scraping: {ex.Message}");
MyBidsCount = bidsUsed, return null;
ResetCount = auction.ResetCount,
Won = won,
WinnerUsername = won ? "ME" : auction.LastState?.LastBidder,
CompletedAt = DateTime.UtcNow,
AverageLatency = auction.LastState != null ? (decimal)auction.LastState.PollingLatencyMs : null, // PollingLatencyMs è int, non nullable
Savings = savings.HasValue ? (decimal)savings.Value : null,
TotalCost = totalCost.HasValue ? (decimal)totalCost.Value : null,
CreatedAt = DateTime.UtcNow
};
_postgresDb.CompletedAuctions.Add(completedAuction);
await _postgresDb.SaveChangesAsync();
// Aggiorna statistiche prodotto
await UpdateProductStatisticsAsync(auction, won, bidsUsed, finalPrice);
// Aggiorna metriche giornaliere
await UpdateDailyMetricsAsync(DateTime.UtcNow.Date, bidsUsed, auction.BidCost, won, savings ?? 0);
Console.WriteLine($"[PostgreSQL] Saved auction {auction.Name} to PostgreSQL");
} }
catch (Exception ex) catch (Exception ex)
{ {
Console.WriteLine($"[PostgreSQL ERROR] Failed to save auction: {ex.Message}"); Console.WriteLine($"[StatsService] ? Errore generico durante scraping: {ex.Message}");
return null;
} }
} }
/// <summary> /// <summary>
/// Aggiorna statistiche prodotto in PostgreSQL /// Estrae le puntate usate dall'HTML (stesso algoritmo di ClosedAuctionsScraper)
/// </summary> /// </summary>
private async Task UpdateProductStatisticsAsync(AuctionInfo auction, bool won, int bidsUsed, double finalPrice) private int? ExtractBidsUsedFromHtml(string html)
{ {
if (_postgresDb == null) return; if (string.IsNullOrEmpty(html)) return null;
// 1) Look for the explicit bids-used span: <p ...><span>628</span> Puntate utilizzate</p>
var match = System.Text.RegularExpressions.Regex.Match(html,
"class=\\\"bids-used\\\"[^>]*>[^<]*<span[^>]*>(?<n>[0-9]{1,7})</span>",
System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Singleline);
if (match.Success && int.TryParse(match.Groups["n"].Value, out var val1))
{
Console.WriteLine($"[StatsService] Puntate estratte (metodo 1 - bids-used class): {val1}");
return val1;
}
// 2) Look for numeric followed by 'Puntate utilizzate' or similar
match = System.Text.RegularExpressions.Regex.Match(html,
"(?<n>[0-9]{1,7})\\s*(?:Puntate utilizzate|Puntate usate|puntate utilizzate|puntate usate|puntate)\\b",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
if (match.Success && int.TryParse(match.Groups["n"].Value, out var val2))
{
Console.WriteLine($"[StatsService] Puntate estratte (metodo 2 - pattern testo): {val2}");
return val2;
}
// 3) Fallbacks
match = System.Text.RegularExpressions.Regex.Match(html,
"(?<n>[0-9]+)\\s*(?:puntate|Puntate|puntate usate|puntate_usate|pt\\.?|pts)\\b",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
if (match.Success && int.TryParse(match.Groups["n"].Value, out var val3))
{
Console.WriteLine($"[StatsService] Puntate estratte (metodo 3 - fallback): {val3}");
return val3;
}
Console.WriteLine($"[StatsService] Nessun pattern trovato per le puntate nell'HTML");
return null;
}
/// <summary>
/// Registra il completamento di un'asta (overload semplificato per compatibilità)
/// </summary>
public async Task RecordAuctionCompletedAsync(AuctionInfo auction, bool won)
{
if (auction.LastState != null)
{
await RecordAuctionCompletedAsync(auction, auction.LastState, won);
}
else
{
Console.WriteLine("[StatsService] Cannot record auction - LastState is null");
}
}
/// <summary>
/// Ottiene i limiti consigliati per un prodotto
/// </summary>
public async Task<RecommendedLimits?> GetRecommendedLimitsAsync(string productName)
{
if (_productStatsService == null) return null;
var productKey = ProductStatisticsService.GenerateProductKey(productName);
return await _productStatsService.GetRecommendedLimitsAsync(productKey);
}
/// <summary>
/// Ottiene le statistiche di un singolo prodotto
/// </summary>
public ProductStatisticsRecord? GetProductStats(string productKey)
{
if (_productStatsService == null || !IsAvailable) return null;
try try
{ {
var productKey = GenerateProductKey(auction.Name); // Carica statistiche dal database in modo sincrono
var stat = await _postgresDb.ProductStatistics.FirstOrDefaultAsync(p => p.ProductKey == productKey); var allStats = _productStatsService.GetAllProductStatisticsAsync().GetAwaiter().GetResult();
return allStats.FirstOrDefault(p => p.ProductKey == productKey);
if (stat == null)
{
stat = new ProductStatistic
{
ProductKey = productKey,
ProductName = auction.Name,
TotalAuctions = 0,
MinBidsSeen = int.MaxValue,
MaxBidsSeen = 0,
CompetitionLevel = "Medium"
};
_postgresDb.ProductStatistics.Add(stat);
} }
catch
stat.TotalAuctions++;
stat.AverageFinalPrice = ((stat.AverageFinalPrice * (stat.TotalAuctions - 1)) + (decimal)finalPrice) / stat.TotalAuctions;
stat.AverageResets = ((stat.AverageResets * (stat.TotalAuctions - 1)) + auction.ResetCount) / stat.TotalAuctions;
if (won)
{ {
stat.AverageWinningBids = ((stat.AverageWinningBids * Math.Max(1, stat.TotalAuctions - 1)) + bidsUsed) / stat.TotalAuctions; return null;
}
stat.MinBidsSeen = Math.Min(stat.MinBidsSeen, bidsUsed);
stat.MaxBidsSeen = Math.Max(stat.MaxBidsSeen, bidsUsed);
stat.RecommendedMaxBids = (int)(stat.AverageWinningBids * 1.5m); // 50% buffer
stat.RecommendedMaxPrice = stat.AverageFinalPrice * 1.2m; // 20% buffer
stat.LastUpdated = DateTime.UtcNow;
// Determina livello competizione
if (stat.AverageWinningBids > 50) stat.CompetitionLevel = "High";
else if (stat.AverageWinningBids < 20) stat.CompetitionLevel = "Low";
else stat.CompetitionLevel = "Medium";
await _postgresDb.SaveChangesAsync();
}
catch (Exception ex)
{
Console.WriteLine($"[PostgreSQL ERROR] Failed to update product statistics: {ex.Message}");
} }
} }
/// <summary> /// <summary>
/// Aggiorna metriche giornaliere in PostgreSQL /// Ottiene tutte le statistiche prodotto
/// </summary> /// </summary>
private async Task UpdateDailyMetricsAsync(DateTime date, int bidsUsed, double bidCost, bool won, double savings) public async Task<List<ProductStatisticsRecord>> GetAllProductStatisticsAsync()
{ {
if (_postgresDb == null) return; if (_productStatsService == null) return new List<ProductStatisticsRecord>();
return await _productStatsService.GetAllProductStatisticsAsync();
try
{
var metric = await _postgresDb.DailyMetrics.FirstOrDefaultAsync(m => m.Date.Date == date.Date);
if (metric == null)
{
metric = new DailyMetric { Date = date.Date };
_postgresDb.DailyMetrics.Add(metric);
} }
metric.TotalBidsUsed += bidsUsed; // Metodi per query statistiche
metric.MoneySpent += (decimal)(bidsUsed * bidCost);
if (won) metric.AuctionsWon++; else metric.AuctionsLost++;
metric.TotalSavings += (decimal)savings;
var totalAuctions = metric.AuctionsWon + metric.AuctionsLost;
if (totalAuctions > 0)
{
metric.WinRate = ((decimal)metric.AuctionsWon / totalAuctions) * 100;
}
if (metric.MoneySpent > 0)
{
metric.ROI = (metric.TotalSavings / metric.MoneySpent) * 100;
}
await _postgresDb.SaveChangesAsync();
}
catch (Exception ex)
{
Console.WriteLine($"[PostgreSQL ERROR] Failed to update daily metrics: {ex.Message}");
}
}
/// <summary>
/// Genera chiave univoca per prodotto
/// </summary>
private string GenerateProductKey(string productName)
{
var normalized = productName.ToLowerInvariant()
.Replace(" ", "_")
.Replace("-", "_");
return System.Text.RegularExpressions.Regex.Replace(normalized, "[^a-z0-9_]", "");
}
/// <summary>
/// Ottiene raccomandazioni strategiche da PostgreSQL
/// </summary>
public async Task<List<StrategicInsight>> GetStrategicInsightsAsync(string? productKey = null)
{
if (!_postgresAvailable || _postgresDb == null)
{
return new List<StrategicInsight>();
}
try
{
var query = _postgresDb.StrategicInsights.Where(i => i.IsActive);
if (!string.IsNullOrEmpty(productKey))
{
query = query.Where(i => i.ProductKey == productKey);
}
return await query.OrderByDescending(i => i.ConfidenceLevel).ToListAsync();
}
catch (Exception ex)
{
Console.WriteLine($"[PostgreSQL ERROR] Failed to get insights: {ex.Message}");
return new List<StrategicInsight>();
}
}
/// <summary>
/// Ottiene performance puntatori da PostgreSQL
/// </summary>
public async Task<List<BidderPerformance>> GetTopCompetitorsAsync(int limit = 10)
{
if (!_postgresAvailable || _postgresDb == null)
{
return new List<BidderPerformance>();
}
try
{
return await _postgresDb.BidderPerformances
.OrderByDescending(b => b.WinRate)
.Take(limit)
.ToListAsync();
}
catch (Exception ex)
{
Console.WriteLine($"[PostgreSQL ERROR] Failed to get competitors: {ex.Message}");
return new List<BidderPerformance>();
}
}
// Metodi esistenti per compatibilità SQLite
public async Task<List<DailyStat>> GetDailyStatsAsync(int days = 30) public async Task<List<DailyStat>> GetDailyStatsAsync(int days = 30)
{ {
if (!IsAvailable)
{
return new List<DailyStat>();
}
var to = DateTime.UtcNow; var to = DateTime.UtcNow;
var from = to.AddDays(-days); var from = to.AddDays(-days);
return await _db.GetDailyStatsAsync(from, to); return await _db.GetDailyStatsAsync(from, to);
@@ -319,6 +361,11 @@ namespace AutoBidder.Services
public async Task<TotalStats> GetTotalStatsAsync() public async Task<TotalStats> GetTotalStatsAsync()
{ {
if (!IsAvailable)
{
return new TotalStats();
}
var stats = await GetDailyStatsAsync(365); var stats = await GetDailyStatsAsync(365);
return new TotalStats return new TotalStats
@@ -338,13 +385,23 @@ namespace AutoBidder.Services
}; };
} }
public async Task<List<AuctionResult>> GetRecentAuctionResultsAsync(int limit = 50) public async Task<List<AuctionResultExtended>> GetRecentAuctionResultsAsync(int limit = 50)
{ {
if (!IsAvailable)
{
return new List<AuctionResultExtended>();
}
return await _db.GetRecentAuctionResultsAsync(limit); return await _db.GetRecentAuctionResultsAsync(limit);
} }
public async Task<double> CalculateROIAsync() public async Task<double> CalculateROIAsync()
{ {
if (!IsAvailable)
{
return 0;
}
var stats = await GetTotalStatsAsync(); var stats = await GetTotalStatsAsync();
if (stats.TotalMoneySpent <= 0) if (stats.TotalMoneySpent <= 0)
@@ -355,11 +412,22 @@ namespace AutoBidder.Services
public async Task<ChartData> GetChartDataAsync(int days = 30) public async Task<ChartData> GetChartDataAsync(int days = 30)
{ {
if (!IsAvailable)
{
return new ChartData
{
Labels = new List<string>(),
MoneySpent = new List<double>(),
Savings = new List<double>()
};
}
var stats = await GetDailyStatsAsync(days); var stats = await GetDailyStatsAsync(days);
var allDates = new List<DailyStat>(); var allDates = new List<DailyStat>();
var startDate = DateTime.UtcNow.AddDays(-days); var startDate = DateTime.UtcNow.AddDays(-days);
for (int i = 0; i < days; i++) for (int i = 0; i < days; i++)
{ {
var date = startDate.AddDays(i).ToString("yyyy-MM-dd"); var date = startDate.AddDays(i).ToString("yyyy-MM-dd");
@@ -387,11 +455,6 @@ namespace AutoBidder.Services
Savings = allDates.Select(s => s.TotalSavings).ToList() Savings = allDates.Select(s => s.TotalSavings).ToList()
}; };
} }
/// <summary>
/// Indica se il database PostgreSQL è disponibile
/// </summary>
public bool IsPostgresAvailable => _postgresAvailable;
} }
// Classi esistenti per compatibilità // Classi esistenti per compatibilità
+20
View File
@@ -0,0 +1,20 @@
@inherits LayoutComponentBase
<div class="login-page">
@Body
</div>
<style>
.login-page {
/* Layout fullscreen per pagina login */
min-height: 100vh;
width: 100vw;
overflow: hidden;
}
/* Nascondi sidebar se presente */
.login-page + .sidebar,
.login-page .sidebar {
display: none !important;
}
</style>
+92 -15
View File
@@ -1,26 +1,103 @@
@inherits LayoutComponentBase @inherits LayoutComponentBase
<div class="page"> <div class="app-container">
<div class="sidebar"> <aside class="app-sidebar">
<NavMenu /> <NavMenu />
</div> </aside>
<main> <main class="app-main">
<!-- UserBanner rimosso - informazioni integrate nel toolbar dell'Index.razor --> <article class="app-content">
<article class="content">
@Body @Body
</article> </article>
</main> </main>
</div> </div>
<div id="blazor-error-ui"> <div id="blazor-error-ui">
<environment include="Staging,Production"> <div class="error-content">
Si è verificato un errore. <i class="bi bi-exclamation-triangle-fill"></i>
</environment> <span>Si e verificato un errore. <a href="" class="reload">Ricarica</a></span>
<environment include="Development"> <button class="dismiss-btn" onclick="this.parentElement.parentElement.style.display='none'">×</button>
Si è verificato un errore non gestito. Consultare la console del browser per ulteriori informazioni. </div>
</environment>
<a href="" class="reload">Ricarica</a>
<a class="dismiss">??</a>
</div> </div>
<style>
.app-container {
display: flex;
min-height: 100vh;
background: #0f0f0f;
}
.app-sidebar {
width: 260px;
min-width: 260px;
height: 100vh;
position: fixed;
left: 0;
top: 0;
z-index: 1000;
}
.app-main {
flex: 1;
margin-left: 260px;
min-height: 100vh;
display: flex;
flex-direction: column;
}
.app-content {
flex: 1;
padding: 1.5rem;
overflow-y: auto;
}
#blazor-error-ui {
display: none;
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 9999;
}
#blazor-error-ui .error-content {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem 1.5rem;
background: linear-gradient(135deg, #dc3545 0%, #c82333 100%);
color: white;
font-weight: 500;
}
#blazor-error-ui .reload {
color: white;
text-decoration: underline;
}
#blazor-error-ui .dismiss-btn {
margin-left: auto;
background: none;
border: none;
color: white;
font-size: 1.5rem;
cursor: pointer;
opacity: 0.8;
}
#blazor-error-ui .dismiss-btn:hover {
opacity: 1;
}
@@media (max-width: 768px) {
.app-sidebar {
width: 100%;
height: auto;
position: relative;
}
.app-main {
margin-left: 0;
}
}
</style>
+272 -76
View File
@@ -1,105 +1,301 @@
@using Microsoft.AspNetCore.Components.Authorization
@inject NavigationManager NavigationManager @inject NavigationManager NavigationManager
@inject AuthenticationStateProvider AuthenticationStateProvider
@inject AuctionMonitor AuctionMonitor
@implements IDisposable
<div class="sidebar"> <div class="nav-sidebar">
<div class="top-row ps-3 navbar navbar-dark"> <div class="nav-header">
<div class="container-fluid"> <a class="nav-brand" href="">
<a class="navbar-brand d-flex align-items-center" href=""> <div class="brand-icon">
<i class="bi bi-lightning-charge-fill me-2" style="font-size: 1.5rem; color: #ffc107;"></i> <i class="bi bi-lightning-charge-fill"></i>
<span class="fw-bold">AutoBidder</span> </div>
<span class="brand-text">AutoBidder</span>
</a> </a>
</div> </div>
<nav class="nav-menu">
<div class="nav-section">
<NavLink class="nav-menu-item" href="" Match="NavLinkMatch.All">
<i class="bi bi-display"></i>
<span>Monitor Aste</span>
</NavLink>
<NavLink class="nav-menu-item" href="browser">
<i class="bi bi-search"></i>
<span>Esplora Aste</span>
</NavLink>
<NavLink class="nav-menu-item" href="statistics">
<i class="bi bi-bar-chart"></i>
<span>Statistiche</span>
</NavLink>
<NavLink class="nav-menu-item" href="settings">
<i class="bi bi-gear"></i>
<span>Impostazioni</span>
</NavLink>
</div> </div>
<div class="nav-scrollable"> <div class="nav-footer">
<nav class="flex-column px-3 mt-3"> <!-- Info Sessione Utente -->
<div class="nav-item px-2 mb-2 animate-fade-in-left stagger-item"> @if (!string.IsNullOrEmpty(sessionUsername))
<NavLink class="nav-link hover-lift transition-all" href="" Match="NavLinkMatch.All"> {
<i class="bi bi-display me-2"></i> Monitor Aste <div class="session-stats">
</NavLink> <div class="session-stat">
<i class="bi bi-hand-index-thumb-fill"></i>
<div class="stat-content">
<span class="stat-label">Puntate</span>
<span class="stat-value @GetBidsClass()">@sessionRemainingBids</span>
</div> </div>
<div class="nav-item px-2 mb-2 animate-fade-in-left stagger-item">
<NavLink class="nav-link hover-lift transition-all" href="freebids">
<i class="bi bi-gift me-2"></i> Puntate Gratuite
</NavLink>
</div> </div>
<div class="nav-item px-2 mb-2 animate-fade-in-left stagger-item"> <div class="session-stat">
<NavLink class="nav-link hover-lift transition-all" href="statistics"> <i class="bi bi-wallet2"></i>
<i class="bi bi-bar-chart me-2"></i> Statistiche <div class="stat-content">
</NavLink> <span class="stat-label">Credito</span>
<span class="stat-value text-success">€@sessionShopCredit.ToString("F2")</span>
</div> </div>
<div class="nav-item px-2 mb-2 animate-fade-in-left stagger-item"> </div>
<NavLink class="nav-link hover-lift transition-all" href="settings"> </div>
<i class="bi bi-gear me-2"></i> Impostazioni }
</NavLink>
<AuthorizeView>
<Authorized>
<div class="user-badge @(string.IsNullOrEmpty(sessionUsername) ? "disconnected" : "connected")">
<i class="bi bi-person-circle"></i>
<span>@(string.IsNullOrEmpty(sessionUsername) ? "Non connesso" : sessionUsername)</span>
</div>
<a href="/Account/Logout" class="nav-menu-item logout-item">
<i class="bi bi-box-arrow-right"></i>
<span>Logout</span>
</a>
</Authorized>
</AuthorizeView>
</div> </div>
</nav> </nav>
</div>
</div> </div>
@code {
private string? sessionUsername;
private int sessionRemainingBids;
private double sessionShopCredit;
private System.Threading.Timer? refreshTimer;
protected override void OnInitialized()
{
LoadSessionInfo();
// Refresh ogni 5 secondi
refreshTimer = new System.Threading.Timer(async _ =>
{
LoadSessionInfo();
await InvokeAsync(StateHasChanged);
}, null, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5));
}
private void LoadSessionInfo()
{
try
{
var session = AuctionMonitor.GetSession();
if (session != null)
{
sessionUsername = session.Username;
sessionRemainingBids = session.RemainingBids;
sessionShopCredit = session.ShopCredit;
}
}
catch { }
}
private string GetBidsClass()
{
if (sessionRemainingBids <= 10) return "text-danger";
if (sessionRemainingBids <= 50) return "text-warning";
return "text-success";
}
public void Dispose()
{
refreshTimer?.Dispose();
}
}
<style> <style>
.sidebar { .nav-sidebar {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
}
.navbar-brand {
font-size: 1.3rem;
transition: all 0.3s ease;
color: white !important;
}
.navbar-brand:hover {
transform: scale(1.05);
text-shadow: 0 0 10px rgba(255, 193, 7, 0.5);
}
.nav-scrollable {
flex: 1;
overflow-y: auto;
padding-bottom: 2rem;
}
.nav-link {
border-radius: 8px;
margin: 0.25rem 0;
padding: 0.75rem 1rem;
font-weight: 500;
position: relative;
overflow: hidden;
color: rgba(255, 255, 255, 0.8) !important;
transition: all 0.3s ease;
}
.nav-link::before {
content: '';
position: absolute;
left: 0;
top: 0;
height: 100%; height: 100%;
width: 4px; background: linear-gradient(180deg, #1a1d23 0%, #13151a 100%);
background: linear-gradient(to bottom, #0dcaf0, #0d6efd); border-right: 1px solid rgba(255, 255, 255, 0.06);
transform: scaleY(0);
transition: transform 0.3s ease;
} }
.nav-link:hover { .nav-header {
background: rgba(255, 255, 255, 0.1); padding: 1.25rem 1.5rem;
color: white !important; border-bottom: 1px solid rgba(255, 255, 255, 0.06);
} }
.nav-link:hover::before, .nav-brand {
.nav-link.active::before { display: flex;
transform: scaleY(1); align-items: center;
gap: 0.75rem;
text-decoration: none;
transition: opacity 0.2s;
} }
.nav-link.active { .nav-brand:hover {
background: linear-gradient(to right, rgba(13, 202, 240, 0.2), transparent); opacity: 0.9;
font-weight: 600; }
color: #0dcaf0 !important;
.brand-icon {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
border-radius: 10px;
font-size: 1.25rem;
color: white;
}
.brand-text {
font-size: 1.25rem;
font-weight: 700;
color: white;
letter-spacing: -0.02em;
}
.nav-menu {
display: flex;
flex-direction: column;
flex: 1;
padding: 1rem 0.75rem;
overflow-y: auto;
}
.nav-section {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.nav-menu-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-radius: 8px;
color: rgba(255, 255, 255, 0.65);
text-decoration: none;
font-size: 0.9375rem;
font-weight: 500;
transition: all 0.15s ease;
}
.nav-menu-item i {
font-size: 1.125rem;
width: 1.5rem;
text-align: center;
}
.nav-menu-item:hover {
background: rgba(255, 255, 255, 0.06);
color: white;
}
.nav-menu-item.active {
background: linear-gradient(135deg, rgba(99, 102, 241, 0.15) 0%, rgba(139, 92, 246, 0.15) 100%);
color: #a5b4fc;
}
.nav-menu-item.active i {
color: #818cf8;
} }
.nav-footer { .nav-footer {
padding: 1rem;
margin-top: auto; margin-top: auto;
padding-top: 1rem;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.session-stats {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem;
margin-bottom: 0.75rem;
background: rgba(255, 255, 255, 0.03);
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.05);
}
.session-stat {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.375rem 0;
}
.session-stat i {
font-size: 0.875rem;
width: 1.25rem;
text-align: center;
color: rgba(255, 255, 255, 0.5);
}
.session-stat .stat-content {
display: flex;
justify-content: space-between;
flex: 1;
align-items: center;
}
.session-stat .stat-label {
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.5);
}
.session-stat .stat-value {
font-size: 0.875rem;
font-weight: 600;
}
.session-stat .text-success { color: #22c55e; }
.session-stat .text-warning { color: #f59e0b; }
.session-stat .text-danger { color: #ef4444; }
.user-badge {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
margin-bottom: 0.5rem;
background: rgba(255, 255, 255, 0.03);
border-radius: 8px;
color: rgba(255, 255, 255, 0.5);
font-size: 0.875rem;
}
.user-badge.connected {
border-left: 3px solid #22c55e;
}
.user-badge.disconnected {
border-left: 3px solid #ef4444;
color: rgba(255, 255, 255, 0.4);
}
.user-badge i {
font-size: 1.25rem;
}
.logout-item {
color: rgba(248, 113, 113, 0.8) !important;
}
.logout-item:hover {
background: rgba(248, 113, 113, 0.1) !important;
color: #f87171 !important;
} }
</style> </style>
+26
View File
@@ -0,0 +1,26 @@
@using Microsoft.AspNetCore.Components
@inject NavigationManager Navigation
<div class="d-flex justify-content-center align-items-center" style="min-height: 100vh; background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);">
<div class="spinner-border text-light" role="status">
<span class="visually-hidden">Caricamento...</span>
</div>
</div>
@code {
private bool _hasRedirected = false;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender && !_hasRedirected)
{
_hasRedirected = true;
// Redirect semplice senza returnUrl per evitare problemi
Navigation.NavigateTo("/Account/Login", forceLoad: true);
}
await base.OnAfterRenderAsync(firstRender);
}
}
+14 -2
View File
@@ -41,7 +41,7 @@ namespace AutoBidder.Utilities
} }
// Calcola risparmio rispetto al prezzo "Compra Subito" // Calcola risparmio rispetto al prezzo "Compra Subito"
if (auctionInfo.BuyNowPrice.HasValue) if (auctionInfo.BuyNowPrice.HasValue && auctionInfo.BuyNowPrice.Value > 0)
{ {
var buyNowTotal = auctionInfo.BuyNowPrice.Value; var buyNowTotal = auctionInfo.BuyNowPrice.Value;
if (auctionInfo.ShippingCost.HasValue) if (auctionInfo.ShippingCost.HasValue)
@@ -50,12 +50,24 @@ namespace AutoBidder.Utilities
} }
value.Savings = buyNowTotal - value.TotalCostIfWin; value.Savings = buyNowTotal - value.TotalCostIfWin;
// ?? FIX: Evita divisione per zero
if (buyNowTotal > 0)
{
value.SavingsPercentage = (value.Savings.Value / buyNowTotal) * 100.0; value.SavingsPercentage = (value.Savings.Value / buyNowTotal) * 100.0;
}
else
{
// Se il buyNowTotal è 0, imposta un valore fittizio negativo per indicare perdita
value.SavingsPercentage = -100.0;
}
value.IsWorthIt = value.Savings.Value > 0; value.IsWorthIt = value.Savings.Value > 0;
} }
else else
{ {
// Senza prezzo "Compra Subito", consideriamo sempre conveniente // Senza prezzo "Compra Subito" valido, consideriamo sempre conveniente
// Questo permette di puntare su aste senza dati di riferimento
value.IsWorthIt = true; value.IsWorthIt = true;
} }
+388 -20
View File
@@ -1,4 +1,4 @@
using System; using System;
using System.IO; using System.IO;
using System.Text.Json; using System.Text.Json;
@@ -7,7 +7,7 @@ namespace AutoBidder.Utilities
public class AppSettings public class AppSettings
{ {
// NUOVE IMPOSTAZIONI PREDEFINITE PER LE ASTE // NUOVE IMPOSTAZIONI PREDEFINITE PER LE ASTE
public int DefaultBidBeforeDeadlineMs { get; set; } = 200; public int DefaultBidBeforeDeadlineMs { get; set; } = 800;
public bool DefaultCheckAuctionOpenBeforeBid { get; set; } = false; public bool DefaultCheckAuctionOpenBeforeBid { get; set; } = false;
public double DefaultMinPrice { get; set; } = 0; public double DefaultMinPrice { get; set; } = 0;
public double DefaultMaxPrice { get; set; } = 0; public double DefaultMaxPrice { get; set; } = 0;
@@ -15,6 +15,33 @@ namespace AutoBidder.Utilities
public int DefaultMinResets { get; set; } = 0; public int DefaultMinResets { get; set; } = 0;
public int DefaultMaxResets { get; set; } = 0; public int DefaultMaxResets { get; set; } = 0;
// ═══════════════════════════════════════════════════════════════════
// TICKER LOOP - SISTEMA DI TIMING SEMPLIFICATO
// ═══════════════════════════════════════════════════════════════════
/// <summary>
/// Intervallo del ticker in millisecondi.
/// Più basso = più preciso ma più CPU.
/// Valori consigliati: 50-100ms
/// Default: 50ms
/// </summary>
public int TickerIntervalMs { get; set; } = 50;
/// <summary>
/// Soglia in millisecondi per iniziare i controlli delle strategie.
/// Se il timer è superiore a questo valore, non vengono eseguiti i controlli.
/// Questo ottimizza le risorse evitando controlli inutili quando siamo lontani dal momento di puntare.
/// Default: 5000ms (5 secondi)
/// </summary>
public int StrategyCheckThresholdMs { get; set; } = 5000;
/// <summary>
/// Mostra avviso quando una puntata arriva troppo tardi (timer scaduto).
/// Suggerisce all'utente di aumentare il tempo di puntata.
/// Default: true
/// </summary>
public bool ShowLateBidWarning { get; set; } = true;
// LIMITI LOG // LIMITI LOG
/// <summary> /// <summary>
/// Numero massimo di righe di log da mantenere per ogni singola asta (default: 500) /// Numero massimo di righe di log da mantenere per ogni singola asta (default: 500)
@@ -49,7 +76,7 @@ namespace AutoBidder.Utilities
// ? NUOVO: LIMITE MINIMO PUNTATE // ? NUOVO: LIMITE MINIMO PUNTATE
/// <summary> /// <summary>
/// Numero minimo di puntate residue da mantenere sull'account. /// Numero minimo di puntate residue da mantenere sull'account.
/// Se impostato > 0, il sistema non punterà se le puntate residue scenderebbero sotto questa soglia. /// Se impostato > 0, il sistema non punterà se le puntate residue scenderebbero sotto questa soglia.
/// Default: 0 (nessun limite) /// Default: 0 (nessun limite)
/// </summary> /// </summary>
public int MinimumRemainingBids { get; set; } = 0; public int MinimumRemainingBids { get; set; } = 0;
@@ -71,26 +98,337 @@ namespace AutoBidder.Utilities
/// </summary> /// </summary>
public string MinLogLevel { get; set; } = "Normal"; public string MinLogLevel { get; set; } = "Normal";
// CONFIGURAZIONE DATABASE POSTGRESQL // ???????????????????????????????????????????????????????????????
/// <summary> // IMPOSTAZIONI DATABASE
/// Abilita l'uso di PostgreSQL per statistiche avanzate // ???????????????????????????????????????????????????????????????
/// </summary>
public bool UsePostgreSQL { get; set; } = true;
/// <summary> /// <summary>
/// Connection string PostgreSQL /// Abilita il salvataggio automatico delle aste completate nel database.
/// Default: true (consigliato per statistiche)
/// </summary> /// </summary>
public string PostgresConnectionString { get; set; } = "Host=localhost;Port=5432;Database=autobidder_stats;Username=autobidder;Password=autobidder_password"; public bool DatabaseAutoSaveEnabled { get; set; } = true;
/// <summary> /// <summary>
/// Auto-crea schema database se mancante /// Esegue pulizia automatica duplicati all'avvio dell'applicazione.
/// Default: true (consigliato per mantenere database pulito)
/// </summary> /// </summary>
public bool AutoCreateDatabaseSchema { get; set; } = true; public bool DatabaseAutoCleanupDuplicates { get; set; } = true;
/// <summary> /// <summary>
/// Fallback automatico a SQLite se PostgreSQL non disponibile /// Esegue pulizia automatica record incompleti all'avvio.
/// Default: false (può rimuovere dati utili in caso di errori temporanei)
/// </summary> /// </summary>
public bool FallbackToSQLite { get; set; } = true; public bool DatabaseAutoCleanupIncomplete { get; set; } = false;
/// <summary>
/// Numero massimo di giorni da mantenere nei risultati aste.
/// Record più vecchi vengono eliminati automaticamente.
/// Default: 180 (6 mesi), 0 = disabilitato
/// </summary>
public int DatabaseMaxRetentionDays { get; set; } = 180;
// ???????????????????????????????????????????????????????????????
// STRATEGIE AVANZATE DI PUNTATA
// ???????????????????????????????????????????????????????????????
// ❌ RIMOSSO: Jitter, Offset Dinamico, Latenza Adattiva
// Il timing è gestito SOLO da DefaultBidBeforeDeadlineMs
// Le strategie decidono SE puntare, non QUANDO
// 🎯 LOGGING GRANULARE
// ???????????????????????????????????????????????????????????????
/// <summary>
/// Log quando viene piazzata una puntata [BID]
/// Default: true
/// </summary>
public bool LogBids { get; set; } = true;
/// <summary>
/// Log quando una strategia blocca la puntata [STRATEGY]
/// Default: true
/// </summary>
public bool LogStrategyDecisions { get; set; } = true;
/// <summary>
/// Log calcoli valore prodotto [VALUE]
/// Default: false (attiva per debug)
/// </summary>
public bool LogValueCalculations { get; set; } = false;
/// <summary>
/// Log rilevamento competizione e heat [COMPETITION]
/// Default: false
/// </summary>
public bool LogCompetition { get; set; } = false;
/// <summary>
/// Log timing e polling (molto verbose!) [TIMING]
/// Default: false (attiva solo per debug timing)
/// </summary>
public bool LogTiming { get; set; } = false;
/// <summary>
/// Log errori e warning [ERROR/WARN]
/// Default: true
/// </summary>
public bool LogErrors { get; set; } = true;
/// <summary>
/// Applica automaticamente i limiti salvati nel prodotto quando si aggiunge una nuova asta.
/// Se TRUE e il prodotto ha valori di default salvati, li applica automaticamente.
/// Default: true (consigliato per coerenza)
/// </summary>
public bool AutoApplyProductDefaults { get; set; } = true;
/// <summary>
/// Scelta priorità limiti quando si aggiunge un'asta per un prodotto già salvato:
/// - "ProductStats": Usa i limiti personalizzati salvati nelle statistiche prodotto (UserDefaultMinPrice, ecc.)
/// - "GlobalDefaults": Usa sempre i limiti globali (DefaultMinPrice, DefaultMaxPrice, ecc.)
/// Default: "ProductStats" (consigliato per usare limiti specifici per prodotto)
/// </summary>
public string NewAuctionLimitsPriority { get; set; } = "ProductStats";
/// <summary>
/// Log stato asta (terminata, reset, ecc.) [STATUS]
/// Default: true
/// </summary>
public bool LogAuctionStatus { get; set; } = true;
/// <summary>
/// Log profiling avversari [OPPONENT]
/// Default: false
/// </summary>
public bool LogOpponentProfiling { get; set; } = false;
// 🎯 STRATEGIE SEMPLIFICATE
/// <summary>
/// Entry Point: Usato SOLO per calcolare i limiti consigliati (70% del MaxPrice storico).
/// NON blocca le puntate! I limiti MinPrice/MaxPrice impostati dall'utente sono RIGIDI.
/// Default: true (per calcolo limiti consigliati)
/// </summary>
public bool EntryPointEnabled { get; set; } = true;
/// <summary>
/// Anti-Bot: Rileva pattern bot (timing identico con varianza minore di 50ms)
/// e evita di competere contro bot automatici.
/// Default: true
/// </summary>
public bool AntiBotDetectionEnabled { get; set; } = true;
/// <summary>
/// User Exhaustion: Sfrutta utenti stanchi (oltre 50 puntate)
/// quando ci sono pochi altri bidder attivi.
/// Default: true
/// </summary>
public bool UserExhaustionEnabled { get; set; } = true;
// 🎯 CONTROLLO CONVENIENZA PRODOTTO
/// <summary>
/// Abilita il controllo di convenienza basato sul valore del prodotto.
/// Se attivo, blocca le puntate quando il costo totale supera il prezzo "Compra Subito"
/// di una percentuale superiore a MinSavingsPercentage.
/// Default: true
/// </summary>
public bool ValueCheckEnabled { get; set; } = true;
/// <summary>
/// Percentuale minima di risparmio richiesta per continuare a puntare.
/// Valori negativi = tolleranza alla perdita.
/// Es: -5 = permetti fino al 5% di perdita rispetto al "Compra Subito"
/// 0 = blocca se costa uguale o più del "Compra Subito"
/// 10 = richiedi almeno 10% di risparmio
/// Default: -5 (permetti fino al 5% di perdita)
/// </summary>
public double MinSavingsPercentage { get; set; } = -5.0;
/// <summary>
/// Abilita il controllo anti-collisione hardcoded.
/// Se attivo, blocca le puntate quando ci sono 3+ bidder attivi negli ultimi 10 secondi.
/// ATTENZIONE: Questo controllo può far perdere aste competitive!
/// Default: false (DISABILITATO - non blocca mai)
/// </summary>
public bool HardcodedAntiCollisionEnabled { get; set; } = false;
// 🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥
// RILEVAMENTO COMPETIZIONE E HEAT METRIC
// 🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥
/// <summary>
/// Abilita rilevamento competizione e heat metric.
/// Conta bidder attivi e collisioni per determinare il "calore" dell'asta.
/// Default: true
/// </summary>
public bool CompetitionDetectionEnabled { get; set; } = true;
/// <summary>
/// Finestra temporale in secondi per contare bidder attivi.
/// Default: 30 (ultimi 30 secondi)
/// </summary>
public int CompetitionWindowSeconds { get; set; } = 30;
/// <summary>
/// Numero minimo di bidder attivi per considerare l'asta "affollata".
/// Se >= a questa soglia, applica logica di evitamento.
/// Default: 3
/// </summary>
public int CompetitionThreshold { get; set; } = 3;
/// <summary>
/// Abilita auto-pausa per aste troppo competitive.
/// Default: false (solo warning, non pausa automatica)
/// </summary>
public bool AutoPauseHotAuctions { get; set; } = false;
/// <summary>
/// Soglia heat metric per auto-pausa (0-100).
/// Default: 80 (pausa se heat >= 80%)
/// </summary>
public int HeatThresholdForPause { get; set; } = 80;
// ???????????????????????????????????????????????????????????????
// SOFT RETREAT E COLLISION MANAGEMENT
// ???????????????????????????????????????????????????????????????
/// <summary>
/// Abilita soft retreat automatico dopo N collisioni consecutive.
/// Default: true
/// </summary>
public bool SoftRetreatEnabled { get; set; } = true;
/// <summary>
/// Numero di collisioni consecutive per attivare soft retreat.
/// Default: 3
/// </summary>
public int SoftRetreatAfterCollisions { get; set; } = 3;
/// <summary>
/// Durata pausa soft retreat in secondi.
/// Default: 30
/// </summary>
public int SoftRetreatDurationSeconds { get; set; } = 30;
// ???????????????????????????????????????????????????????????????
// PROBABILISTIC BIDDING
// ???????????????????????????????????????????????????????????????
/// <summary>
/// Abilita policy di puntata probabilistica.
/// Decide se puntare con probabilità p basata su competizione e ROI.
/// Default: false (richiede tuning)
/// </summary>
public bool ProbabilisticBiddingEnabled { get; set; } = false;
/// <summary>
/// Probabilità base di puntata (0.0 - 1.0).
/// Default: 0.8 (80%)
/// </summary>
public double BaseBidProbability { get; set; } = 0.8;
/// <summary>
/// Fattore di riduzione probabilità per ogni bidder attivo extra.
/// Default: 0.1 (riduce del 10% per ogni bidder oltre la soglia)
/// </summary>
public double ProbabilityReductionPerBidder { get; set; } = 0.1;
// ???????????????????????????????????????????????????????????????
// OPPONENT PROFILING
// ???????????????????????????????????????????????????????????????
/// <summary>
/// Abilita profiling degli avversari.
/// Identifica utenti aggressivi e applica regole specifiche.
/// Default: true
/// </summary>
public bool OpponentProfilingEnabled { get; set; } = true;
/// <summary>
/// Soglia puntate per considerare un utente "aggressivo".
/// Default: 10 (se un utente ha fatto >= 10 puntate in un'asta)
/// </summary>
public int AggressiveBidderThreshold { get; set; } = 10;
/// <summary>
/// Dimensione finestra scorrevole per analisi bidder aggressivi.
/// Analizza le ultime N puntate invece del conteggio totale.
/// Default: 30 (ultime 30 puntate)
/// </summary>
public int AggressiveBidderWindowSize { get; set; } = 30;
/// <summary>
/// Soglia percentuale per considerare un utente "aggressivo".
/// Se un utente ha più di X% delle puntate nella finestra, è aggressivo.
/// Default: 40 (40% delle puntate)
/// </summary>
public double AggressiveBidderPercentageThreshold { get; set; } = 40.0;
/// <summary>
/// Dimensione finestra per rilevamento situazioni di duello.
/// Default: 20 (ultime 20 puntate)
/// </summary>
public int DuelDetectionWindowSize { get; set; } = 20;
/// <summary>
/// Azione da intraprendere con bidder aggressivi.
/// "Avoid" = evita l'asta, "Compete" = continua normalmente, "Outbid" = punta più aggressivamente
/// Default: "Compete" (cambiato da Avoid per essere meno restrittivo)
/// </summary>
public string AggressiveBidderAction { get; set; } = "Compete";
// ???????????????????????????????????????????????????????????????
// BANKROLL & SAFETY MANAGER
// ???????????????????????????????????????????????????????????????
/// <summary>
/// Abilita gestione bankroll per limitare spese.
/// Default: true
/// </summary>
public bool BankrollManagerEnabled { get; set; } = true;
/// <summary>
/// Limite massimo puntate per sessione (0 = illimitato).
/// Default: 0
/// </summary>
public int MaxBidsPerSession { get; set; } = 0;
/// <summary>
/// Limite massimo puntate per singola asta (0 = illimitato).
/// Default: 0
/// </summary>
public int MaxBidsPerAuction { get; set; } = 0;
/// <summary>
/// Budget massimo giornaliero in euro (0 = illimitato).
/// Calcolato come: puntate usate × costo medio puntata.
/// Default: 0
/// </summary>
public double DailyBudgetEuro { get; set; } = 0;
/// <summary>
/// Costo medio per puntata in euro (per calcolo budget).
/// Default: 0.15
/// </summary>
public double AverageBidCostEuro { get; set; } = 0.15;
// ???????????????????????????????????????????????????????????????
// LOGGING AVANZATO
// ???????????????????????????????????????????????????????????????
/// <summary>
/// Abilita logging avanzato con metriche dettagliate.
/// Include: collisioni, timer scaduto, latenza, heat metric.
/// Default: true
/// </summary>
public bool AdvancedLoggingEnabled { get; set; } = true;
/// <summary>
/// Salva metriche per ogni puntata nel database.
/// Default: true
/// </summary>
public bool SaveBidMetricsToDatabase { get; set; } = true;
} }
public static class SettingsManager public static class SettingsManager
@@ -98,17 +436,40 @@ namespace AutoBidder.Utilities
private static readonly string _folder = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "AutoBidder"); private static readonly string _folder = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "AutoBidder");
private static readonly string _file = Path.Combine(_folder, "settings.json"); private static readonly string _file = Path.Combine(_folder, "settings.json");
// Cache per evitare letture disco ripetute nel hot path (ticker loop 50ms)
private static readonly object _cacheLock = new();
private static AppSettings? _cached;
private static DateTime _cacheExpiry = DateTime.MinValue;
private const int CACHE_TTL_MS = 2000; // Ricarica da disco al massimo ogni 2s
public static AppSettings Load() public static AppSettings Load()
{ {
lock (_cacheLock)
{
var now = DateTime.UtcNow;
if (_cached != null && now < _cacheExpiry)
return _cached;
try try
{ {
if (!File.Exists(_file)) return new AppSettings(); if (!File.Exists(_file))
var txt = File.ReadAllText(_file); {
var s = JsonSerializer.Deserialize<AppSettings>(txt); _cached = new AppSettings();
if (s == null) return new AppSettings(); }
return s; else
{
var txt = File.ReadAllText(_file);
_cached = JsonSerializer.Deserialize<AppSettings>(txt) ?? new AppSettings();
}
}
catch
{
_cached ??= new AppSettings();
}
_cacheExpiry = now.AddMilliseconds(CACHE_TTL_MS);
return _cached;
} }
catch { return new AppSettings(); }
} }
public static void Save(AppSettings settings) public static void Save(AppSettings settings)
@@ -118,6 +479,13 @@ namespace AutoBidder.Utilities
if (!Directory.Exists(_folder)) Directory.CreateDirectory(_folder); if (!Directory.Exists(_folder)) Directory.CreateDirectory(_folder);
var txt = JsonSerializer.Serialize(settings, new JsonSerializerOptions { WriteIndented = true }); var txt = JsonSerializer.Serialize(settings, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(_file, txt); File.WriteAllText(_file, txt);
// Invalida cache così il prossimo Load() legge i nuovi valori
lock (_cacheLock)
{
_cached = settings;
_cacheExpiry = DateTime.UtcNow.AddMilliseconds(CACHE_TTL_MS);
}
} }
catch { } catch { }
} }
+178
View File
@@ -0,0 +1,178 @@
# bump-version.ps1
# Script per incrementare automaticamente la versione del progetto
# Uso: .\bump-version.ps1 -Type [major|minor|patch]
param(
[Parameter(Mandatory=$true)]
[ValidateSet('major','minor','patch')]
[string]$Type,
[Parameter(Mandatory=$false)]
[string]$Message = ""
)
$ErrorActionPreference = "Stop"
Write-Host "?????????????????????????????????????????????????????????????????????" -ForegroundColor Cyan
Write-Host "? AutoBidder Version Bump Tool ?" -ForegroundColor Cyan
Write-Host "?????????????????????????????????????????????????????????????????????" -ForegroundColor Cyan
Write-Host ""
# File da aggiornare
$csprojFile = "AutoBidder.csproj"
$dockerFile = "Dockerfile"
$changelogFile = "CHANGELOG.md"
# Leggi versione corrente da .csproj
Write-Host "?? Lettura versione corrente..." -ForegroundColor Yellow
$csprojContent = Get-Content $csprojFile -Raw
$versionMatch = [regex]::Match($csprojContent, '<Version>(.*?)</Version>')
if (-not $versionMatch.Success) {
Write-Host "? Impossibile trovare tag <Version> in $csprojFile" -ForegroundColor Red
exit 1
}
$currentVersion = $versionMatch.Groups[1].Value
Write-Host " Versione corrente: $currentVersion" -ForegroundColor Gray
# Parse semantic version
$parts = $currentVersion -split '\.'
if ($parts.Length -ne 3) {
Write-Host "? Formato versione non valido: $currentVersion (atteso: MAJOR.MINOR.PATCH)" -ForegroundColor Red
exit 1
}
$major = [int]$parts[0]
$minor = [int]$parts[1]
$patch = [int]$parts[2]
# Incrementa in base al tipo
Write-Host ""
Write-Host "?? Incremento versione ($Type)..." -ForegroundColor Yellow
switch ($Type) {
'major' {
$major++
$minor = 0
$patch = 0
Write-Host " MAJOR version bump (breaking changes)" -ForegroundColor Magenta
}
'minor' {
$minor++
$patch = 0
Write-Host " MINOR version bump (nuove feature)" -ForegroundColor Blue
}
'patch' {
$patch++
Write-Host " PATCH version bump (bug fix)" -ForegroundColor Green
}
}
$newVersion = "$major.$minor.$patch"
$today = Get-Date -Format "yyyy-MM-dd"
Write-Host ""
Write-Host " $currentVersion ? $newVersion" -ForegroundColor White -BackgroundColor DarkGreen
Write-Host ""
# Aggiorna AutoBidder.csproj
Write-Host "?? Aggiornamento AutoBidder.csproj..." -ForegroundColor Yellow
$csprojContent = $csprojContent -replace '<Version>.*?</Version>', "<Version>$newVersion</Version>"
$csprojContent = $csprojContent -replace '<AssemblyVersion>.*?</AssemblyVersion>', "<AssemblyVersion>$newVersion.0</AssemblyVersion>"
$csprojContent = $csprojContent -replace '<FileVersion>.*?</FileVersion>', "<FileVersion>$newVersion.0</FileVersion>"
$csprojContent = $csprojContent -replace '<InformationalVersion>.*?</InformationalVersion>', "<InformationalVersion>$newVersion</InformationalVersion>"
Set-Content $csprojFile $csprojContent -NoNewline
Write-Host " ? $csprojFile aggiornato" -ForegroundColor Green
# Aggiorna Dockerfile
Write-Host "?? Aggiornamento Dockerfile..." -ForegroundColor Yellow
$dockerContent = Get-Content $dockerFile -Raw
$dockerContent = $dockerContent -replace 'org\.opencontainers\.image\.version=".*?"', "org.opencontainers.image.version=""$newVersion"""
Set-Content $dockerFile $dockerContent -NoNewline
Write-Host " ? $dockerFile aggiornato" -ForegroundColor Green
# Prepara voce CHANGELOG
Write-Host ""
Write-Host "?? Preparazione CHANGELOG.md..." -ForegroundColor Yellow
$changelogEntry = @"
## [$newVersion] - $today
### ? Aggiunte (Added)
-
### ?? Modifiche (Changed)
-
### ?? Correzioni (Fixed)
-
### ??? Rimossi (Removed)
-
### ?? Breaking Changes
-
---
"@
# Leggi CHANGELOG esistente
$changelogContent = Get-Content $changelogFile -Raw
# Trova dove inserire (dopo l'intestazione, prima della prima release)
$insertPattern = "(---\s*\n\s*)"
if ($changelogContent -match $insertPattern) {
$changelogContent = $changelogContent -replace $insertPattern, "$changelogEntry`$1"
} else {
# Fallback: aggiungi alla fine
$changelogContent = $changelogContent + "`n" + $changelogEntry
}
Set-Content $changelogFile $changelogContent -NoNewline
Write-Host " ? Template CHANGELOG aggiunto per v$newVersion" -ForegroundColor Green
Write-Host ""
Write-Host "?????????????????????????????????????????????????????????????????????" -ForegroundColor Green
Write-Host "? ? VERSIONE AGGIORNATA CON SUCCESSO! ?" -ForegroundColor Green
Write-Host "?????????????????????????????????????????????????????????????????????" -ForegroundColor Green
Write-Host ""
Write-Host "?? Nuova versione: v$newVersion" -ForegroundColor White -BackgroundColor DarkGreen
Write-Host "?? Data: $today" -ForegroundColor Gray
Write-Host ""
Write-Host "?? PROSSIMI PASSI:" -ForegroundColor Cyan
Write-Host ""
Write-Host " 1. Compila CHANGELOG.md con le modifiche effettuate" -ForegroundColor White
Write-Host " 2. Verifica le modifiche:" -ForegroundColor White
Write-Host " git diff" -ForegroundColor Gray
Write-Host ""
Write-Host " 3. Commit le modifiche:" -ForegroundColor White
Write-Host " git add AutoBidder.csproj Dockerfile CHANGELOG.md" -ForegroundColor Gray
Write-Host " git commit -m ""chore: bump version to v$newVersion""" -ForegroundColor Gray
Write-Host ""
Write-Host " 4. Crea tag Git:" -ForegroundColor White
Write-Host " git tag v$newVersion" -ForegroundColor Gray
Write-Host " git push origin docker --tags" -ForegroundColor Gray
Write-Host ""
Write-Host " 5. Pubblica su Gitea:" -ForegroundColor White
Write-Host " Visual Studio: Tasto destro ? Pubblica ? GiteaRegistry" -ForegroundColor Gray
Write-Host " Oppure: dotnet publish /p:PublishProfile=GiteaRegistry" -ForegroundColor Gray
Write-Host ""
Write-Host "???????????????????????????????????????????????????????????????????" -ForegroundColor Cyan
Write-Host ""
# Mostra summary files modificati
Write-Host "?? File modificati:" -ForegroundColor Yellow
Write-Host "$csprojFile" -ForegroundColor Gray
Write-Host "$dockerFile" -ForegroundColor Gray
Write-Host "$changelogFile" -ForegroundColor Gray
Write-Host ""
+12 -45
View File
@@ -1,31 +1,6 @@
version: '3.8' version: '3.8'
services: services:
# ================================================
# PostgreSQL Database (statistiche avanzate)
# ================================================
postgres:
image: postgres:16-alpine
container_name: autobidder-postgres
environment:
POSTGRES_DB: autobidder_stats
POSTGRES_USER: ${POSTGRES_USER:-autobidder}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-autobidder_password}
POSTGRES_INITDB_ARGS: --encoding=UTF8
volumes:
- postgres-data:/var/lib/postgresql/data
- ./postgres-backups:/backups
ports:
- "5432:5432"
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-autobidder} -d autobidder_stats"]
interval: 10s
timeout: 5s
retries: 5
networks:
- autobidder-network
# ================================================ # ================================================
# AutoBidder Application # AutoBidder Application
# ================================================ # ================================================
@@ -35,35 +10,31 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
args: args:
BUILD_CONFIGURATION: Release BUILD_CONFIGURATION: Release
image: gitea.encke-hake.ts.net/alby96/mimante/autobidder:latest image: gitea.encke-hake.ts.net/alby96/autobidder:latest
container_name: autobidder container_name: autobidder
depends_on:
postgres:
condition: service_healthy
ports: ports:
- "${APP_PORT:-8080}:8080" # HTTP only (simpler for Docker) - "${APP_PORT:-5000}:8080" # Host:Container (HTTP only)
volumes: volumes:
# Persistent data (SQLite, backups, logs) # Persistent data (SQLite databases, backups, logs, keys)
# Tutti i dati persistenti sono salvati in questo volume
- ./Data:/app/Data - ./Data:/app/Data
# PostgreSQL backups
- ./postgres-backups:/app/Data/backups
environment: environment:
# ASP.NET Core # ASP.NET Core
- ASPNETCORE_ENVIRONMENT=Production - ASPNETCORE_ENVIRONMENT=Production
- ASPNETCORE_URLS=http://+:8080 - ASPNETCORE_URLS=http://+:8080
# PostgreSQL connection # ============================================
- ConnectionStrings__PostgreSQL=Host=postgres;Port=5432;Database=autobidder_stats;Username=${POSTGRES_USER:-autobidder};Password=${POSTGRES_PASSWORD:-autobidder_password} # DATABASE PATH - Volume persistente Docker
# ============================================
# Tutti i database SQLite e dati persistenti usano questo path
- DATA_PATH=/app/Data
# Database settings # Autenticazione applicazione (SICUREZZA)
- Database__UsePostgres=${USE_POSTGRES:-true} - ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
- Database__AutoCreateSchema=true - ADMIN_PASSWORD=${ADMIN_PASSWORD}
- Database__FallbackToSQLite=true
# Logging # Logging
- Logging__LogLevel__Default=${LOG_LEVEL:-Information} - Logging__LogLevel__Default=${LOG_LEVEL:-Information}
- Logging__LogLevel__Microsoft.EntityFrameworkCore=Warning
# Timezone # Timezone
- TZ=Europe/Rome - TZ=Europe/Rome
@@ -77,10 +48,6 @@ services:
networks: networks:
- autobidder-network - autobidder-network
volumes:
postgres-data:
driver: local
networks: networks:
autobidder-network: autobidder-network:
driver: bridge driver: bridge
+12 -7
View File
@@ -299,17 +299,22 @@
transition: transform 0.3s ease, box-shadow 0.3s ease; transition: transform 0.3s ease, box-shadow 0.3s ease;
} }
/* ?? RIMOSSO: hover-lift causava movimento fastidioso */
.hover-lift:hover { .hover-lift:hover {
transform: translateY(-4px); /* transform: translateY(-4px); - RIMOSSO */
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15); box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
background-color: rgba(255, 255, 255, 0.05);
} }
/* ?? RIMOSSO: hover-scale causava zoom fastidioso */
.hover-scale { .hover-scale {
transition: transform 0.3s ease; transition: background-color 0.2s ease, border-color 0.2s ease;
} }
.hover-scale:hover { .hover-scale:hover {
transform: scale(1.05); /* transform: scale(1.05); - RIMOSSO */
background-color: rgba(255, 255, 255, 0.1);
border-color: rgba(13, 110, 253, 0.5);
} }
.hover-rotate { .hover-rotate {
@@ -412,8 +417,9 @@
transition: all 0.2s ease; transition: all 0.2s ease;
} }
/* Rimosso effetto scale sulle righe - era fastidioso */
.table tbody tr:hover { .table tbody tr:hover {
transform: scale(1.01); /* transform: scale(1.01); - RIMOSSO */
z-index: 1; z-index: 1;
} }
@@ -431,8 +437,7 @@
transition: all 0.3s ease; transition: all 0.3s ease;
} }
.badge:hover { /* Rimosso effetto scale su badge hover */
transform: scale(1.1);
} }
.badge-pulse { .badge-pulse {
+18 -6
View File
@@ -585,55 +585,67 @@ body {
.btn-success { .btn-success {
background: var(--success-color); background: var(--success-color);
color: white; color: white;
transition: filter 0.2s ease, box-shadow 0.2s ease;
} }
.btn-success:hover:not(:disabled) { .btn-success:hover:not(:disabled) {
background: #059669; filter: brightness(1.1);
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.3);
} }
.btn-warning { .btn-warning {
background: var(--warning-color); background: var(--warning-color);
color: white; color: white;
transition: filter 0.2s ease, box-shadow 0.2s ease;
} }
.btn-warning:hover:not(:disabled) { .btn-warning:hover:not(:disabled) {
background: #d97706; filter: brightness(1.1);
box-shadow: 0 2px 8px rgba(245, 158, 11, 0.3);
} }
.btn-danger { .btn-danger {
background: var(--danger-color); background: var(--danger-color);
color: white; color: white;
transition: filter 0.2s ease, box-shadow 0.2s ease;
} }
.btn-danger:hover:not(:disabled) { .btn-danger:hover:not(:disabled) {
background: #dc2626; filter: brightness(1.1);
box-shadow: 0 2px 8px rgba(239, 68, 68, 0.3);
} }
.btn-primary { .btn-primary {
background: var(--primary-color); background: var(--primary-color);
color: white; color: white;
transition: filter 0.2s ease, box-shadow 0.2s ease;
} }
.btn-primary:hover:not(:disabled) { .btn-primary:hover:not(:disabled) {
background: #0284c7; filter: brightness(1.1);
box-shadow: 0 2px 8px rgba(14, 165, 233, 0.3);
} }
.btn-secondary { .btn-secondary {
background: var(--bg-hover); background: var(--bg-hover);
color: var(--text-secondary); color: var(--text-secondary);
transition: filter 0.2s ease, box-shadow 0.2s ease;
} }
.btn-secondary:hover:not(:disabled) { .btn-secondary:hover:not(:disabled) {
background: var(--text-muted); filter: brightness(1.15);
box-shadow: 0 2px 8px rgba(100, 116, 139, 0.2);
} }
.btn-info { .btn-info {
background: var(--info-color); background: var(--info-color);
color: white; color: white;
transition: filter 0.2s ease, box-shadow 0.2s ease;
} }
.btn-info:hover:not(:disabled) { .btn-info:hover:not(:disabled) {
background: #2563eb; filter: brightness(1.1);
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3);
} }
.btn:disabled { .btn:disabled {
+365 -96
View File
@@ -1,68 +1,271 @@
/* app-wpf.css - WPF Dark Theme + Modern Sidebar */ /* app-wpf.css - Modern Dark Theme */
:root { :root {
/* WPF Dark Theme Palette */ /* Modern Dark Palette */
--bg-primary: #1e1e1e; --bg-primary: #0f0f0f;
--bg-secondary: #252526; --bg-secondary: #171717;
--bg-tertiary: #2d2d30; --bg-tertiary: #1f1f1f;
--bg-hover: #3e3e42; --bg-card: #1a1a1a;
--bg-selected: #094771; --bg-hover: #262626;
--border-color: #3e3e42; --bg-selected: #2d2d2d;
--text-primary: #ffffff; --border-color: rgba(255, 255, 255, 0.08);
--text-secondary: #cccccc; --border-subtle: rgba(255, 255, 255, 0.04);
--text-muted: #808080;
/* WPF Accent Colors */ /* Text Colors */
--primary-color: #007acc; --text-primary: #fafafa;
--success-color: #00d800; --text-secondary: #a1a1aa;
--warning-color: #ffb700; --text-muted: #71717a;
--danger-color: #e81123;
--info-color: #00b7c3;
/* Log Syntax Colors */ /* Accent Colors */
--log-success: #00d800; --primary: #6366f1;
--log-warning: #ffb700; --primary-hover: #4f46e5;
--log-error: #f48771; --success: #22c55e;
--log-info: #4ec9b0; --warning: #f59e0b;
--log-debug: #569cd6; --danger: #ef4444;
--log-timestamp: #808080; --info: #3b82f6;
/* Gradients */
--gradient-primary: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
--gradient-success: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
--gradient-danger: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
/* Shadows */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.4);
/* Border Radius */
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--radius-xl: 20px;
} }
/* === GLOBAL === */ /* === GLOBAL RESET === */
* { *, *::before, *::after {
box-sizing: border-box; box-sizing: border-box;
margin: 0;
padding: 0;
} }
body { body {
margin: 0; font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: var(--bg-primary); background: var(--bg-primary);
color: var(--text-secondary); color: var(--text-secondary);
font-size: 13px; font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
} }
/* === LAYOUT === */ /* === SCROLLBAR === */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.15);
}
/* === LAYOUT (legacy support) === */
.page { .page {
display: flex; display: flex;
height: 100vh; height: 100vh;
overflow: hidden; overflow: hidden;
} }
/* Sidebar Moderna - 250px come prima */ /* === MODERN CARD COMPONENT === */
.card-modern {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 1.5rem;
transition: all 0.2s ease;
}
.card-modern:hover {
border-color: rgba(255, 255, 255, 0.12);
}
.card-header-modern {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border-subtle);
}
.card-title {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 0.5rem;
}
.card-title i {
color: var(--primary);
}
/* === MODERN BUTTON === */
.btn-modern {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.625rem 1.25rem;
font-size: 0.875rem;
font-weight: 500;
border-radius: var(--radius-md);
border: none;
cursor: pointer;
transition: all 0.15s ease;
}
.btn-primary-modern {
background: var(--gradient-primary);
color: white;
}
.btn-primary-modern:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.35);
}
.btn-success-modern {
background: var(--gradient-success);
color: white;
}
.btn-danger-modern {
background: var(--gradient-danger);
color: white;
}
.btn-ghost {
background: transparent;
border: 1px solid var(--border-color);
color: var(--text-secondary);
}
.btn-ghost:hover {
background: var(--bg-hover);
border-color: rgba(255, 255, 255, 0.15);
color: var(--text-primary);
}
/* === MODERN INPUT === */
.input-modern {
width: 100%;
padding: 0.75rem 1rem;
font-size: 0.875rem;
color: var(--text-primary);
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
transition: all 0.15s ease;
}
.input-modern:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15);
}
.input-modern::placeholder {
color: var(--text-muted);
}
/* === BADGE === */
.badge-modern {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.625rem;
font-size: 0.75rem;
font-weight: 500;
border-radius: 9999px;
}
.badge-success {
background: rgba(34, 197, 94, 0.15);
color: #4ade80;
}
.badge-warning {
background: rgba(245, 158, 11, 0.15);
color: #fbbf24;
}
.badge-danger {
background: rgba(239, 68, 68, 0.15);
color: #f87171;
}
.badge-info {
background: rgba(59, 130, 246, 0.15);
color: #60a5fa;
}
/* === STAT CARD === */
.stat-card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: 1.25rem;
}
.stat-card-label {
font-size: 0.8125rem;
color: var(--text-muted);
margin-bottom: 0.5rem;
}
.stat-card-value {
font-size: 1.75rem;
font-weight: 700;
color: var(--text-primary);
line-height: 1.2;
}
.stat-card-change {
font-size: 0.8125rem;
margin-top: 0.5rem;
}
.stat-card-change.positive {
color: var(--success);
}
.stat-card-change.negative {
color: var(--danger);
}
/* Sidebar Moderna - 260px */
.sidebar { .sidebar {
width: 250px; width: 260px;
height: 100vh; height: 100vh;
position: fixed; position: fixed;
left: 0; left: 0;
top: 0; top: 0;
background: linear-gradient(180deg, #1c2128 0%, #161b22 50%, #0d1117 100%); background: linear-gradient(180deg, #1a1d23 0%, #13151a 100%);
border-right: 1px solid var(--border-color); border-right: 1px solid var(--border-color);
z-index: 1000; z-index: 1000;
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.5);
} }
main { main {
margin-left: 250px; margin-left: 260px;
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -355,6 +558,7 @@ main {
overflow: auto; overflow: auto;
} }
/* Splitter verticale tra griglia e log */ /* Splitter verticale tra griglia e log */
.splitter-vertical { .splitter-vertical {
grid-column: 2; grid-column: 2;
@@ -363,22 +567,28 @@ main {
cursor: col-resize; cursor: col-resize;
position: relative; position: relative;
transition: background 0.2s ease; transition: background 0.2s ease;
min-width: 6px;
width: 6px;
} }
.splitter-vertical:hover { .splitter-vertical:hover {
background: var(--primary-color); background: var(--primary);
} }
.splitter-vertical::after { .splitter-vertical::before {
content: ''; content: '';
position: absolute; position: absolute;
left: 50%; left: 50%;
top: 50%; top: 50%;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
width: 2px; color: var(--text-muted);
height: 40px; font-size: 16px;
background: var(--text-muted); opacity: 0.5;
border-radius: 1px; }
.splitter-vertical:hover::before {
color: white;
opacity: 1;
} }
/* Log globale - colonna destra */ /* Log globale - colonna destra */
@@ -395,7 +605,7 @@ main {
/* Splitter orizzontale tra top e dettagli */ /* Splitter orizzontale tra top e dettagli */
.splitter-horizontal { .splitter-horizontal {
height: 4px; height: 6px;
background: var(--border-color); background: var(--border-color);
cursor: row-resize; cursor: row-resize;
position: relative; position: relative;
@@ -404,19 +614,23 @@ main {
} }
.splitter-horizontal:hover { .splitter-horizontal:hover {
background: var(--primary-color); background: var(--primary);
} }
.splitter-horizontal::after { .splitter-horizontal::before {
content: ''; content: '';
position: absolute; position: absolute;
left: 50%; left: 50%;
top: 50%; top: 50%;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
width: 40px; color: var(--text-muted);
height: 2px; font-size: 16px;
background: var(--text-muted); opacity: 0.5;
border-radius: 1px; }
.splitter-horizontal:hover::before {
color: white;
opacity: 1;
} }
/* Dettagli asta - sotto splitter orizzontale */ /* Dettagli asta - sotto splitter orizzontale */
@@ -500,8 +714,9 @@ main {
height: 100%; height: 100%;
} }
/* 🔥 COMPATTATO: Ridotto padding per massimizzare spazio */
.tab-panel-content { .tab-panel-content {
padding: 1rem; padding: 0.5rem 0.75rem;
} }
/* === GRADIENTS FOR CARDS === */ /* === GRADIENTS FOR CARDS === */
@@ -669,24 +884,78 @@ main {
background: var(--bg-tertiary); background: var(--bg-tertiary);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 3px; border-radius: 3px;
padding: 0.75rem; padding: 0.5rem;
margin: 0.5rem; margin: 0.25rem;
} }
/* 🔥 COMPATTATO: Ridotto margin e padding per info-group */
.info-group { .info-group {
margin-bottom: 0.75rem; margin-bottom: 0.4rem;
} }
.info-group label { .info-group label {
display: block; display: block;
font-weight: 600; font-weight: 600;
margin-bottom: 0.25rem; margin-bottom: 0.15rem;
color: var(--text-secondary); color: var(--text-secondary);
font-size: 0.813rem; font-size: 0.75rem;
}
/* 🔥 COMPATTATO: Input più piccoli */
.info-group input.form-control,
.info-group select.form-control {
padding: 0.25rem 0.5rem;
font-size: 0.85rem;
height: auto;
}
/* 🔥 GRIGLIA IMPOSTAZIONI COMPATTA */
.settings-grid-compact {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.settings-grid-compact .setting-item {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.settings-grid-compact .setting-item label {
font-size: 0.7rem;
color: var(--text-secondary);
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.settings-grid-compact .setting-item label i {
margin-right: 0.2rem;
}
/* 🔥 Input stretti per valori numerici */
.input-narrow {
max-width: 90px !important;
text-align: center;
padding: 0.2rem 0.4rem !important;
font-size: 0.8rem !important;
}
/* Responsive: su schermi piccoli, 2 colonne */
@media (max-width: 768px) {
.settings-grid-compact {
grid-template-columns: repeat(2, 1fr);
}
.input-narrow {
max-width: 100% !important;
}
} }
.auction-log, .bidders-stats { .auction-log, .bidders-stats {
margin: 0.5rem; margin: 0.25rem;
} }
.auction-log h4, .bidders-stats h4 { .auction-log h4, .bidders-stats h4 {
@@ -1024,56 +1293,56 @@ main {
margin-right: 0.5rem; margin-right: 0.5rem;
} }
/* === PRODUCT INFO COMPATTO === */ /* === PRODUCT INFO COMPATTO === */
.product-info-compact { .product-info-compact {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 0.5rem;
} }
/* Card info principali - orizzontali */ /* Card info principali - orizzontali compatte */
.info-cards { .info-cards {
display: grid; display: grid;
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
gap: 0.75rem; gap: 0.4rem;
} }
.info-card { .info-card {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.5rem;
padding: 0.75rem 1rem; padding: 0.4rem 0.6rem;
border-radius: 6px; border-radius: 4px;
border: 1px solid; border: 1px solid;
transition: all 0.2s ease; transition: background-color 0.2s ease;
} }
.info-card:hover { .info-card:hover {
transform: translateY(-1px); background: var(--bg-hover);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
} }
.info-card i { .info-card i {
font-size: 1.75rem; font-size: 1.1rem;
flex-shrink: 0; flex-shrink: 0;
} }
.info-card div { .info-card div {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.125rem; gap: 0;
} }
.info-card small { .info-card small {
font-size: 0.688rem; font-size: 0.6rem;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.3px;
color: var(--text-muted); color: var(--text-muted);
font-weight: 500; font-weight: 500;
} }
.info-card strong { .info-card strong {
font-size: 1.125rem; font-size: 0.9rem;
font-weight: 700; font-weight: 700;
color: var(--text-primary); color: var(--text-primary);
} }
@@ -1096,26 +1365,26 @@ main {
color: var(--info-color); color: var(--info-color);
} }
/* Calcoli inline - 4 colonne */ /* Calcoli inline - 4 colonne compatte */
.calc-inline { .calc-inline {
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(4, 1fr);
gap: 0.5rem; gap: 0.3rem;
padding: 0.75rem; padding: 0.4rem;
background: var(--bg-tertiary); background: var(--bg-tertiary);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 6px; border-radius: 4px;
} }
.calc-item { .calc-item {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 0.25rem; gap: 0.1rem;
padding: 0.5rem; padding: 0.25rem;
text-align: center; text-align: center;
border-radius: 4px; border-radius: 3px;
transition: all 0.2s ease; transition: background-color 0.2s ease;
} }
.calc-item:hover { .calc-item:hover {
@@ -1128,7 +1397,7 @@ main {
} }
.calc-item i { .calc-item i {
font-size: 1.25rem; font-size: 0.9rem;
color: var(--primary-color); color: var(--primary-color);
} }
@@ -1137,13 +1406,13 @@ main {
} }
.calc-item .label { .calc-item .label {
font-size: 0.688rem; font-size: 0.6rem;
color: var(--text-muted); color: var(--text-muted);
font-weight: 500; font-weight: 500;
} }
.calc-item .value { .calc-item .value {
font-size: 1rem; font-size: 0.85rem;
font-weight: 700; font-weight: 700;
color: var(--text-primary); color: var(--text-primary);
} }
@@ -1152,30 +1421,30 @@ main {
.totals-compact { .totals-compact {
display: grid; display: grid;
grid-template-columns: 1fr 1fr auto; grid-template-columns: 1fr 1fr auto;
gap: 0.75rem; gap: 0.4rem;
align-items: center; align-items: center;
} }
.total-item { .total-item {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.25rem; gap: 0.1rem;
padding: 0.75rem; padding: 0.4rem 0.6rem;
background: var(--bg-tertiary); background: var(--bg-tertiary);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 6px; border-radius: 4px;
} }
.total-item span { .total-item span {
font-size: 0.75rem; font-size: 0.65rem;
color: var(--text-muted); color: var(--text-muted);
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.375rem; gap: 0.2rem;
} }
.total-item strong { .total-item strong {
font-size: 1.125rem; font-size: 0.9rem;
font-weight: 700; font-weight: 700;
} }
@@ -1195,10 +1464,10 @@ main {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 0.5rem; gap: 0.3rem;
padding: 0.75rem 1.5rem; padding: 0.4rem 0.8rem;
border-radius: 6px; border-radius: 4px;
font-size: 1rem; font-size: 0.75rem;
font-weight: 700; font-weight: 700;
white-space: nowrap; white-space: nowrap;
} }
@@ -1216,7 +1485,7 @@ main {
} }
.verdict-badge i { .verdict-badge i {
font-size: 1.125rem; font-size: 0.85rem;
} }
/* === RESPONSIVE === */ /* === RESPONSIVE === */
@@ -1310,8 +1579,8 @@ main {
.table-fixed .col-prezzo { width: 90px; } .table-fixed .col-prezzo { width: 90px; }
.table-fixed .col-timer { width: 90px; } .table-fixed .col-timer { width: 90px; }
.table-fixed .col-ultimo { width: 120px; } .table-fixed .col-ultimo { width: 120px; }
.table-fixed .col-click { width: 70px; text-align: center; } .table-fixed .col-click { width: 90px; text-align: center; padding-right: 10px; }
.table-fixed .col-ping { width: 80px; } .table-fixed .col-ping { width: 90px; padding-left: 10px; }
.table-fixed .col-azioni { width: 150px; } .table-fixed .col-azioni { width: 150px; }
.table-fixed td { .table-fixed td {
File diff suppressed because it is too large Load Diff
+15
View File
@@ -76,6 +76,7 @@
window.Blazor.addEventListener('enhancedload', initLogScroll); window.Blazor.addEventListener('enhancedload', initLogScroll);
} }
// Esporta funzione per forzare scroll // Esporta funzione per forzare scroll
window.forceLogScrollToBottom = function () { window.forceLogScrollToBottom = function () {
logBoxes.forEach(logBox => { logBoxes.forEach(logBox => {
@@ -83,4 +84,18 @@
scrollToBottom(logBox); scrollToBottom(logBox);
}); });
}; };
// Funzione chiamabile da Blazor per scroll specifico elemento
window.scrollToBottom = function (elementId) {
const element = document.getElementById(elementId);
if (element) {
// Controlla se siamo già in fondo o quasi (entro 100px)
const isNearBottom = element.scrollHeight - element.scrollTop - element.clientHeight < 100;
// Auto-scroll solo se siamo già in fondo (non interrompe lettura manuale)
if (isNearBottom || !userScrolling.get(element)) {
element.scrollTop = element.scrollHeight;
}
}
};
})(); })();