minor fixes
This commit is contained in:
@@ -26,6 +26,7 @@ class ConfigureMeilisearchIndex extends Command
|
||||
*/
|
||||
private const SORTABLE_ATTRIBUTES = [
|
||||
'created_at',
|
||||
'published_at_ts',
|
||||
'trending_score_24h',
|
||||
'trending_score_7d',
|
||||
'favorites_count',
|
||||
|
||||
419
app/Console/Commands/HealthCheckCommand.php
Normal file
419
app/Console/Commands/HealthCheckCommand.php
Normal file
@@ -0,0 +1,419 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
use Meilisearch\Client as MeilisearchClient;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Comprehensive service health check.
|
||||
*
|
||||
* Usage:
|
||||
* php artisan health:check # all checks
|
||||
* php artisan health:check --only=meili # single service
|
||||
* php artisan health:check --json # machine-readable JSON output
|
||||
*/
|
||||
class HealthCheckCommand extends Command
|
||||
{
|
||||
protected $signature = 'health:check
|
||||
{--only= : Run only a named check (mysql|redis|cache|meilisearch|reverb|vision|horizon|app)}
|
||||
{--json : Output results as JSON}';
|
||||
|
||||
protected $description = 'Check health of all critical services (MySQL, Redis, Cache, Meilisearch, Reverb, Vision, Horizon, App).';
|
||||
|
||||
/** Collected results: [name => [status, message, details]] */
|
||||
private array $results = [];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$only = $this->option('only') ? strtolower((string) $this->option('only')) : null;
|
||||
|
||||
$checks = [
|
||||
'mysql' => fn () => $this->checkMysql(),
|
||||
'redis' => fn () => $this->checkRedis(),
|
||||
'cache' => fn () => $this->checkCache(),
|
||||
'meilisearch' => fn () => $this->checkMeilisearch(),
|
||||
'reverb' => fn () => $this->checkReverb(),
|
||||
'vision' => fn () => $this->checkVision(),
|
||||
'horizon' => fn () => $this->checkHorizon(),
|
||||
'app' => fn () => $this->checkApp(),
|
||||
];
|
||||
|
||||
if ($only !== null) {
|
||||
if (! array_key_exists($only, $checks)) {
|
||||
$this->error("Unknown check '{$only}'. Available: " . implode(', ', array_keys($checks)));
|
||||
return self::FAILURE;
|
||||
}
|
||||
$checks = [$only => $checks[$only]];
|
||||
}
|
||||
|
||||
foreach ($checks as $name => $check) {
|
||||
$check();
|
||||
}
|
||||
|
||||
if ($this->option('json')) {
|
||||
$this->line(json_encode($this->results, JSON_PRETTY_PRINT));
|
||||
return $this->hasFailures() ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->renderTable();
|
||||
|
||||
$failed = $this->countByStatus('fail');
|
||||
$warned = $this->countByStatus('warn');
|
||||
|
||||
$this->newLine();
|
||||
if ($failed > 0) {
|
||||
$this->error("❌ {$failed} check(s) FAILED" . ($warned > 0 ? ", {$warned} warning(s)" : '') . '.');
|
||||
return self::FAILURE;
|
||||
}
|
||||
if ($warned > 0) {
|
||||
$this->warn("⚠️ All checks passed with {$warned} warning(s).");
|
||||
return self::SUCCESS;
|
||||
}
|
||||
$this->info('✅ All checks passed.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
// ── Individual checks ──────────────────────────────────────────────────────
|
||||
|
||||
private function checkMysql(): void
|
||||
{
|
||||
try {
|
||||
DB::select('SELECT 1');
|
||||
$db = config('database.connections.' . config('database.default') . '.database');
|
||||
$artworkCount = DB::table('artworks')->whereNull('deleted_at')->count();
|
||||
$this->pass('mysql', "Connected to `{$db}`. Artworks in DB: {$artworkCount}.", ['artwork_count' => $artworkCount]);
|
||||
} catch (Throwable $e) {
|
||||
$this->failCheck('mysql', 'Connection failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function checkRedis(): void
|
||||
{
|
||||
try {
|
||||
$pong = Redis::ping();
|
||||
// ping returns "+PONG\r\n" string or true depending on driver
|
||||
$ok = $pong === true || str_contains((string) $pong, 'PONG');
|
||||
if ($ok) {
|
||||
$info = Redis::info('server');
|
||||
$version = $info['redis_version'] ?? ($info['Server']['redis_version'] ?? 'unknown');
|
||||
$this->pass('redis', "Reachable. Redis version: {$version}.", ['version' => $version]);
|
||||
} else {
|
||||
$this->failCheck('redis', 'Unexpected ping response: ' . var_export($pong, true));
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$this->failCheck('redis', 'Connection failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function checkCache(): void
|
||||
{
|
||||
try {
|
||||
$key = '_healthcheck_' . uniqid('', true);
|
||||
$value = 'ok_' . time();
|
||||
Cache::put($key, $value, 10);
|
||||
$got = Cache::get($key);
|
||||
Cache::forget($key);
|
||||
|
||||
$driver = config('cache.default');
|
||||
if ($got === $value) {
|
||||
$this->pass('cache', "Driver `{$driver}` read/write OK.", ['driver' => $driver]);
|
||||
} else {
|
||||
$this->failCheck('cache', "Driver `{$driver}`: wrote '{$value}' but read back " . var_export($got, true));
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$this->failCheck('cache', 'Cache test failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function checkMeilisearch(): void
|
||||
{
|
||||
try {
|
||||
/** @var MeilisearchClient $client */
|
||||
$client = app(MeilisearchClient::class);
|
||||
$health = $client->health();
|
||||
|
||||
if (($health['status'] ?? '') !== 'available') {
|
||||
$this->failCheck('meilisearch', 'Meilisearch reports unhealthy status: ' . json_encode($health));
|
||||
return;
|
||||
}
|
||||
|
||||
$version = $client->version()['pkgVersion'] ?? 'unknown';
|
||||
$indexName = (new Artwork())->searchableAs();
|
||||
$index = $client->index($indexName);
|
||||
$stats = $index->stats();
|
||||
$docCount = (int) ($stats['numberOfDocuments'] ?? 0);
|
||||
$isIndexing = (bool) ($stats['isIndexing'] ?? false);
|
||||
|
||||
// Expected: ≥ 50% of DB artworks should be indexed
|
||||
$dbCount = DB::table('artworks')
|
||||
->whereNull('deleted_at')
|
||||
->where('is_public', 1)
|
||||
->where('is_approved', 1)
|
||||
->count();
|
||||
|
||||
$status = 'pass';
|
||||
$message = "v{$version}. Index `{$indexName}`: {$docCount} docs (DB public+approved: {$dbCount}).";
|
||||
|
||||
if ($isIndexing) {
|
||||
$message .= ' [currently re-indexing]';
|
||||
}
|
||||
|
||||
if ($docCount === 0) {
|
||||
$status = 'fail';
|
||||
$message = "Index `{$indexName}` is EMPTY (DB has {$dbCount} public+approved artworks). Run: php artisan scout:import \"App\\\\Models\\\\Artwork\"";
|
||||
} elseif ($dbCount > 0 && $docCount < (int) ($dbCount * 0.5)) {
|
||||
$status = 'warn';
|
||||
$message .= " — indexed count is < 50% of DB count. Index may be stale. Run: php artisan artworks:search-rebuild";
|
||||
}
|
||||
|
||||
// Check pending Meilisearch tasks
|
||||
try {
|
||||
$tasks = $client->getTasks(['statuses' => 'enqueued,processing']);
|
||||
$pendingCount = $tasks->getTotal();
|
||||
if ($pendingCount > 0) {
|
||||
$message .= " ({$pendingCount} tasks still pending)";
|
||||
}
|
||||
} catch (Throwable) {
|
||||
// non-fatal
|
||||
}
|
||||
|
||||
$this->result('meilisearch', $status, $message, [
|
||||
'index' => $indexName,
|
||||
'indexed_docs' => $docCount,
|
||||
'db_eligible' => $dbCount,
|
||||
'is_indexing' => $isIndexing,
|
||||
'meili_version' => $version,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
$this->failCheck('meilisearch', 'Unreachable or error: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function checkReverb(): void
|
||||
{
|
||||
$host = config('reverb.servers.reverb.options.host') ?? env('REVERB_HOST', '');
|
||||
$port = (int) (config('reverb.servers.reverb.options.port') ?? env('REVERB_PORT', 443));
|
||||
$scheme = config('reverb.servers.reverb.options.scheme') ?? env('REVERB_SCHEME', 'https');
|
||||
|
||||
if (empty($host)) {
|
||||
$this->warn_check('reverb', 'REVERB_HOST not configured — skipping.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Reverb exposes an HTTP health endpoint at /apps/{appId}
|
||||
// We do a plain TCP connect as the minimal check; a refused connection means down.
|
||||
$timeout = 5;
|
||||
try {
|
||||
$errno = 0;
|
||||
$errstr = '';
|
||||
$proto = $scheme === 'https' ? 'ssl' : 'tcp';
|
||||
$fp = @fsockopen("{$proto}://{$host}", $port, $errno, $errstr, $timeout);
|
||||
|
||||
if ($fp === false) {
|
||||
$this->failCheck('reverb', "Cannot connect to {$host}:{$port} ({$scheme}) — {$errstr} [{$errno}]");
|
||||
return;
|
||||
}
|
||||
|
||||
fclose($fp);
|
||||
|
||||
// Try the HTTP health probe (Reverb answers 200 on /)
|
||||
$url = "{$scheme}://{$host}:{$port}/";
|
||||
$response = $this->httpGet($url, 3);
|
||||
$code = $response['code'] ?? 0;
|
||||
|
||||
// Reverb returns 200 or 400 on the root — both mean it's alive
|
||||
if ($code >= 200 && $code < 500) {
|
||||
$this->pass('reverb', "Reachable at {$host}:{$port} (HTTP {$code}).", ['host' => $host, 'port' => $port]);
|
||||
} else {
|
||||
$this->warn_check('reverb', "TCP open but HTTP returned {$code}.", ['host' => $host, 'port' => $port]);
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$this->failCheck('reverb', 'Check failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function checkVision(): void
|
||||
{
|
||||
$services = [
|
||||
'CLIP / Gateway' => rtrim((string) config('vision.gateway.base_url', ''), '/') . '/health',
|
||||
'Vector Gateway' => rtrim((string) config('vision.vector_gateway.base_url', ''), '/') . '/health',
|
||||
];
|
||||
|
||||
$allPassed = true;
|
||||
$messages = [];
|
||||
|
||||
foreach ($services as $label => $url) {
|
||||
if ($url === '/health' || $url === '') {
|
||||
$messages[] = "{$label}: not configured";
|
||||
continue;
|
||||
}
|
||||
|
||||
$response = $this->httpGet($url, 5);
|
||||
$code = $response['code'] ?? 0;
|
||||
|
||||
if ($code >= 200 && $code < 300) {
|
||||
$messages[] = "{$label}: OK (HTTP {$code})";
|
||||
} elseif ($code === 0) {
|
||||
$allPassed = false;
|
||||
$messages[] = "{$label}: UNREACHABLE ({$url})";
|
||||
} else {
|
||||
$allPassed = false;
|
||||
$messages[] = "{$label}: HTTP {$code} ({$url})";
|
||||
}
|
||||
}
|
||||
|
||||
$summary = implode(' | ', $messages);
|
||||
|
||||
if ($allPassed) {
|
||||
$this->pass('vision', $summary);
|
||||
} else {
|
||||
$this->warn_check('vision', $summary);
|
||||
}
|
||||
}
|
||||
|
||||
private function checkHorizon(): void
|
||||
{
|
||||
try {
|
||||
// Horizon stores its status in Redis under the horizon:master-supervisor key prefix.
|
||||
// A simpler cross-version check: look for any horizon-related Redis key.
|
||||
$status = Cache::store('redis')->get('horizon:status');
|
||||
|
||||
if ($status === null) {
|
||||
// Try reading directly from Redis
|
||||
$status = Redis::get('horizon:status');
|
||||
}
|
||||
|
||||
if ($status === null) {
|
||||
$this->warn_check('horizon', 'No Horizon status key in Redis — Horizon may not be running or has never started.');
|
||||
return;
|
||||
}
|
||||
|
||||
$status = is_string($status) ? strtolower(trim($status)) : strtolower((string) $status);
|
||||
|
||||
if ($status === 'running') {
|
||||
$this->pass('horizon', "Horizon status: running.");
|
||||
} elseif ($status === 'paused') {
|
||||
$this->warn_check('horizon', "Horizon is PAUSED. Resume with: php artisan horizon:continue");
|
||||
} else {
|
||||
$this->failCheck('horizon', "Horizon status: {$status}. Start with: php artisan horizon");
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$this->warn_check('horizon', 'Could not read Horizon status: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function checkApp(): void
|
||||
{
|
||||
$appUrl = rtrim((string) config('app.url', ''), '/');
|
||||
|
||||
if (empty($appUrl) || str_contains($appUrl, '.test') || str_contains($appUrl, 'localhost')) {
|
||||
$this->warn_check('app', "APP_URL is `{$appUrl}` — looks like a local/dev URL, skipping HTTP probe.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Probe the app homepage
|
||||
$response = $this->httpGet($appUrl . '/', 10);
|
||||
$code = $response['code'] ?? 0;
|
||||
|
||||
if ($code === 200) {
|
||||
$ttfb = $response['ttfb'] ?? 0;
|
||||
$this->pass('app', "Homepage responded HTTP 200. TTFB: {$ttfb}ms.", ['url' => $appUrl, 'ttfb_ms' => $ttfb]);
|
||||
} elseif ($code > 0) {
|
||||
$this->warn_check('app', "Homepage returned HTTP {$code}. URL: {$appUrl}", ['url' => $appUrl, 'http_code' => $code]);
|
||||
} else {
|
||||
$this->failCheck('app', "Homepage unreachable. URL: {$appUrl}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
private function httpGet(string $url, int $timeout = 5): array
|
||||
{
|
||||
$start = microtime(true);
|
||||
try {
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => $timeout,
|
||||
CURLOPT_CONNECTTIMEOUT => 3,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_MAXREDIRS => 3,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_USERAGENT => 'SkinbaseHealthCheck/1.0',
|
||||
CURLOPT_HTTPHEADER => ['Accept: application/json, text/html'],
|
||||
]);
|
||||
$body = curl_exec($ch);
|
||||
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
$ttfb = (int) round((microtime(true) - $start) * 1000);
|
||||
return ['code' => $code, 'body' => $body ?: '', 'ttfb' => $ttfb];
|
||||
} catch (Throwable) {
|
||||
return ['code' => 0, 'body' => '', 'ttfb' => 0];
|
||||
}
|
||||
}
|
||||
|
||||
private function pass(string $name, string $message, array $details = []): void
|
||||
{
|
||||
$this->result($name, 'pass', $message, $details);
|
||||
}
|
||||
|
||||
private function failCheck(string $name, string $message, array $details = []): void
|
||||
{
|
||||
$this->result($name, 'fail', $message, $details);
|
||||
}
|
||||
|
||||
private function warn_check(string $name, string $message, array $details = []): void
|
||||
{
|
||||
$this->result($name, 'warn', $message, $details);
|
||||
}
|
||||
|
||||
private function result(string $name, string $status, string $message, array $details = []): void
|
||||
{
|
||||
$this->results[$name] = [
|
||||
'status' => $status,
|
||||
'message' => $message,
|
||||
'details' => $details,
|
||||
];
|
||||
}
|
||||
|
||||
private function renderTable(): void
|
||||
{
|
||||
$this->newLine();
|
||||
$this->line(' <fg=white;options=bold>SERVICE STATUS MESSAGE</>');
|
||||
$this->line(' ' . str_repeat('─', 90));
|
||||
|
||||
foreach ($this->results as $name => $r) {
|
||||
[$icon, $color] = match ($r['status']) {
|
||||
'pass' => ['✅', 'green'],
|
||||
'warn' => ['⚠️ ', 'yellow'],
|
||||
default => ['❌', 'red'],
|
||||
};
|
||||
|
||||
$label = str_pad(strtoupper($name), 15);
|
||||
$status = str_pad(strtoupper($r['status']), 7);
|
||||
$message = $r['message'];
|
||||
|
||||
$this->line(" {$icon} <fg={$color}>{$label} {$status}</> {$message}");
|
||||
}
|
||||
|
||||
$this->line(' ' . str_repeat('─', 90));
|
||||
}
|
||||
|
||||
private function hasFailures(): bool
|
||||
{
|
||||
return $this->countByStatus('fail') > 0;
|
||||
}
|
||||
|
||||
private function countByStatus(string $status): int
|
||||
{
|
||||
return count(array_filter($this->results, fn ($r) => $r['status'] === $status));
|
||||
}
|
||||
}
|
||||
@@ -13,12 +13,14 @@ use Throwable;
|
||||
*
|
||||
* Usage:
|
||||
* php artisan skinbase:import-legacy-artworks --chunk=500 --dry-run
|
||||
* php artisan skinbase:import-legacy-artworks --artwork-id=69527
|
||||
*/
|
||||
class ImportLegacyArtworks extends Command
|
||||
{
|
||||
protected $signature = 'skinbase:import-legacy-artworks
|
||||
{--chunk=500 : chunk size for processing}
|
||||
{--limit= : maximum number of legacy rows to import}
|
||||
{--artwork-id= : import only one legacy wallz row by id}
|
||||
{--dry-run : do not persist any changes}
|
||||
{--legacy-connection=legacy : name of legacy DB connection}
|
||||
{--legacy-table=wallz : legacy artworks table name}
|
||||
@@ -73,15 +75,28 @@ class ImportLegacyArtworks extends Command
|
||||
{
|
||||
$chunk = (int) $this->option('chunk');
|
||||
$limit = $this->option('limit') ? (int) $this->option('limit') : null;
|
||||
$artworkId = $this->option('artwork-id') ? (int) $this->option('artwork-id') : null;
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$legacyConn = $this->option('legacy-connection');
|
||||
$legacyTable = $this->option('legacy-table');
|
||||
$connectedTable = $this->option('connected-table');
|
||||
|
||||
if ($artworkId !== null && $artworkId <= 0) {
|
||||
$this->error('The --artwork-id option must be a positive integer.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info("Starting import from {$legacyConn}.{$legacyTable} (chunk={$chunk})");
|
||||
|
||||
$query = DB::connection($legacyConn)->table($legacyTable)->orderBy('id');
|
||||
|
||||
if ($artworkId !== null) {
|
||||
$this->info("Scoping import to legacy artwork id={$artworkId}");
|
||||
$query->where('id', $artworkId);
|
||||
$limit = 1;
|
||||
}
|
||||
|
||||
$processed = 0;
|
||||
|
||||
$query->chunkById($chunk, function ($rows) use (&$processed, $limit, $dryRun, $legacyConn, $connectedTable) {
|
||||
@@ -277,8 +292,14 @@ class ImportLegacyArtworks extends Command
|
||||
return null;
|
||||
}, 'id');
|
||||
|
||||
if ($artworkId !== null && $processed === 0) {
|
||||
$this->warn("Legacy artwork id={$artworkId} was not found in {$legacyConn}.{$legacyTable}.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info('Import complete. Processed: ' . $processed);
|
||||
|
||||
return 0;
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,21 +12,22 @@ use Illuminate\Support\Facades\Log;
|
||||
* Runs every 10–15 minutes via scheduler.
|
||||
*
|
||||
* Formula:
|
||||
* raw_heat = views_delta*1 + downloads_delta*3 + favourites_delta*6
|
||||
* + comments_delta*8 + shares_delta*12
|
||||
* raw_heat = ((views_delta*1 + downloads_delta*3 + favourites_delta*6
|
||||
* + comments_delta*8 + shares_delta*12) / window_hours)
|
||||
*
|
||||
* age_factor = 1 / (1 + hours_since_upload / 24)
|
||||
*
|
||||
* heat_score = raw_heat * age_factor
|
||||
*
|
||||
* Usage: php artisan nova:recalculate-heat
|
||||
* php artisan nova:recalculate-heat --days=60 --chunk=1000 --dry-run
|
||||
* php artisan nova:recalculate-heat --days=60 --chunk=1000 --lookback-hours=24 --dry-run
|
||||
*/
|
||||
class RecalculateHeatCommand extends Command
|
||||
{
|
||||
protected $signature = 'nova:recalculate-heat
|
||||
{--days=60 : Only process artworks created within this many days}
|
||||
{--chunk=1000 : Chunk size for DB queries}
|
||||
{--lookback-hours=24 : Smooth heat deltas over this many trailing hours}
|
||||
{--dry-run : Compute scores without writing to DB}';
|
||||
|
||||
protected $description = 'Recalculate heat/momentum scores for the Rising engine';
|
||||
@@ -44,31 +45,34 @@ class RecalculateHeatCommand extends Command
|
||||
{
|
||||
$days = (int) $this->option('days');
|
||||
$chunk = (int) $this->option('chunk');
|
||||
$lookbackHours = max(1, (int) $this->option('lookback-hours'));
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$now = now();
|
||||
$currentHour = $now->copy()->startOfHour();
|
||||
$prevHour = $currentHour->copy()->subHour();
|
||||
$lookbackStart = $currentHour->copy()->subHours($lookbackHours);
|
||||
|
||||
$this->info("[nova:recalculate-heat] current_hour={$currentHour->toDateTimeString()} prev_hour={$prevHour->toDateTimeString()} days={$days}" . ($dryRun ? ' (dry-run)' : ''));
|
||||
$this->info("[nova:recalculate-heat] current_hour={$currentHour->toDateTimeString()} prev_hour={$prevHour->toDateTimeString()} lookback_start={$lookbackStart->toDateTimeString()} lookback_hours={$lookbackHours} days={$days}" . ($dryRun ? ' (dry-run)' : ''));
|
||||
|
||||
$updatedCount = 0;
|
||||
$skippedCount = 0;
|
||||
|
||||
// Process in chunks using artwork IDs that have at least one snapshot in the two hours
|
||||
// Process in chunks using artwork IDs that have at least one snapshot in the smoothing window
|
||||
$artworkIds = DB::table('artwork_metric_snapshots_hourly')
|
||||
->whereIn('bucket_hour', [$currentHour, $prevHour])
|
||||
->whereBetween('bucket_hour', [$lookbackStart, $currentHour])
|
||||
->distinct()
|
||||
->pluck('artwork_id');
|
||||
|
||||
if ($artworkIds->isEmpty()) {
|
||||
$this->warn('No snapshots found for the current or previous hour. Run nova:metrics-snapshot-hourly first.');
|
||||
$this->warn('No snapshots found inside the requested lookback window. Run nova:metrics-snapshot-hourly first.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
// Load all snapshots for the two hours in bulk
|
||||
// Load all snapshots for the lookback window in bulk
|
||||
$snapshots = DB::table('artwork_metric_snapshots_hourly')
|
||||
->whereIn('bucket_hour', [$currentHour, $prevHour])
|
||||
->whereBetween('bucket_hour', [$lookbackStart, $currentHour])
|
||||
->whereIn('artwork_id', $artworkIds)
|
||||
->orderBy('bucket_hour')
|
||||
->get()
|
||||
->groupBy('artwork_id');
|
||||
|
||||
@@ -101,27 +105,57 @@ class RecalculateHeatCommand extends Command
|
||||
}
|
||||
|
||||
$currentSnapshot = $artworkSnapshots->firstWhere('bucket_hour', $currentHour->toDateTimeString());
|
||||
$prevSnapshot = $artworkSnapshots->firstWhere('bucket_hour', $prevHour->toDateTimeString());
|
||||
if (! $currentSnapshot) {
|
||||
$currentSnapshot = $artworkSnapshots->last();
|
||||
}
|
||||
|
||||
// If we only have one snapshot, use it as current with zero deltas
|
||||
if (!$currentSnapshot && !$prevSnapshot) {
|
||||
$prevSnapshot = $artworkSnapshots->firstWhere('bucket_hour', $prevHour->toDateTimeString());
|
||||
$baselineSnapshot = $artworkSnapshots
|
||||
->filter(fn ($snapshot) => (string) $snapshot->bucket_hour < (string) ($currentSnapshot->bucket_hour ?? ''))
|
||||
->first();
|
||||
|
||||
if (! $currentSnapshot) {
|
||||
$skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate deltas
|
||||
$viewsDelta = max(0, (int) ($currentSnapshot?->views_count ?? 0) - (int) ($prevSnapshot?->views_count ?? 0));
|
||||
$downloadsDelta = max(0, (int) ($currentSnapshot?->downloads_count ?? 0) - (int) ($prevSnapshot?->downloads_count ?? 0));
|
||||
$favouritesDelta = max(0, (int) ($currentSnapshot?->favourites_count ?? 0) - (int) ($prevSnapshot?->favourites_count ?? 0));
|
||||
$commentsDelta = max(0, (int) ($currentSnapshot?->comments_count ?? 0) - (int) ($prevSnapshot?->comments_count ?? 0));
|
||||
$sharesDelta = max(0, (int) ($currentSnapshot?->shares_count ?? 0) - (int) ($prevSnapshot?->shares_count ?? 0));
|
||||
// One-hour counters remain explicit fields for dashboards and debugging.
|
||||
$viewsDelta1h = max(0, (int) ($currentSnapshot?->views_count ?? 0) - (int) ($prevSnapshot?->views_count ?? 0));
|
||||
$downloadsDelta1h = max(0, (int) ($currentSnapshot?->downloads_count ?? 0) - (int) ($prevSnapshot?->downloads_count ?? 0));
|
||||
$favouritesDelta1h = max(0, (int) ($currentSnapshot?->favourites_count ?? 0) - (int) ($prevSnapshot?->favourites_count ?? 0));
|
||||
$commentsDelta1h = max(0, (int) ($currentSnapshot?->comments_count ?? 0) - (int) ($prevSnapshot?->comments_count ?? 0));
|
||||
$sharesDelta1h = max(0, (int) ($currentSnapshot?->shares_count ?? 0) - (int) ($prevSnapshot?->shares_count ?? 0));
|
||||
|
||||
// Smooth the heat signal over a trailing window so low-traffic periods do not flatten Rising.
|
||||
// A single snapshot without an earlier baseline should not count as new momentum.
|
||||
if ($baselineSnapshot) {
|
||||
$viewsDelta = max(0, (int) ($currentSnapshot?->views_count ?? 0) - (int) ($baselineSnapshot->views_count ?? 0));
|
||||
$downloadsDelta = max(0, (int) ($currentSnapshot?->downloads_count ?? 0) - (int) ($baselineSnapshot->downloads_count ?? 0));
|
||||
$favouritesDelta = max(0, (int) ($currentSnapshot?->favourites_count ?? 0) - (int) ($baselineSnapshot->favourites_count ?? 0));
|
||||
$commentsDelta = max(0, (int) ($currentSnapshot?->comments_count ?? 0) - (int) ($baselineSnapshot->comments_count ?? 0));
|
||||
$sharesDelta = max(0, (int) ($currentSnapshot?->shares_count ?? 0) - (int) ($baselineSnapshot->shares_count ?? 0));
|
||||
|
||||
$windowHours = max(
|
||||
1.0,
|
||||
abs($currentHour->copy()->parse($currentSnapshot->bucket_hour)->floatDiffInHours($currentHour->copy()->parse($baselineSnapshot->bucket_hour)))
|
||||
);
|
||||
} else {
|
||||
$viewsDelta = 0;
|
||||
$downloadsDelta = 0;
|
||||
$favouritesDelta = 0;
|
||||
$commentsDelta = 0;
|
||||
$sharesDelta = 0;
|
||||
$windowHours = 1.0;
|
||||
}
|
||||
|
||||
// Raw heat
|
||||
$rawHeat = ($viewsDelta * self::WEIGHTS['views'])
|
||||
+ ($downloadsDelta * self::WEIGHTS['downloads'])
|
||||
+ ($favouritesDelta * self::WEIGHTS['favourites'])
|
||||
+ ($commentsDelta * self::WEIGHTS['comments'])
|
||||
+ ($sharesDelta * self::WEIGHTS['shares']);
|
||||
$rawHeat = (
|
||||
($viewsDelta * self::WEIGHTS['views'])
|
||||
+ ($downloadsDelta * self::WEIGHTS['downloads'])
|
||||
+ ($favouritesDelta * self::WEIGHTS['favourites'])
|
||||
+ ($commentsDelta * self::WEIGHTS['comments'])
|
||||
+ ($sharesDelta * self::WEIGHTS['shares'])
|
||||
) / $windowHours;
|
||||
|
||||
// Age factor: favors newer works
|
||||
$hoursSinceUpload = abs($now->floatDiffInHours($createdAt));
|
||||
@@ -134,11 +168,11 @@ class RecalculateHeatCommand extends Command
|
||||
'artwork_id' => $artworkId,
|
||||
'heat_score' => round($heatScore, 4),
|
||||
'heat_score_updated_at' => $now,
|
||||
'views_1h' => $viewsDelta,
|
||||
'downloads_1h' => $downloadsDelta,
|
||||
'favourites_1h' => $favouritesDelta,
|
||||
'comments_1h' => $commentsDelta,
|
||||
'shares_1h' => $sharesDelta,
|
||||
'views_1h' => $viewsDelta1h,
|
||||
'downloads_1h' => $downloadsDelta1h,
|
||||
'favourites_1h' => $favouritesDelta1h,
|
||||
'comments_1h' => $commentsDelta1h,
|
||||
'shares_1h' => $sharesDelta1h,
|
||||
];
|
||||
|
||||
$updatedCount++;
|
||||
|
||||
@@ -13,7 +13,7 @@ use Carbon\Carbon;
|
||||
|
||||
class RepairLegacyWallzUsersCommand extends Command
|
||||
{
|
||||
protected $signature = 'skinbase:repair-legacy-wallz-users
|
||||
protected $signature = 'legacySB:repair-legacy-wallz-users
|
||||
{--chunk=500 : Number of legacy wallz rows to scan per batch}
|
||||
{--legacy-connection=legacy : Legacy database connection name}
|
||||
{--legacy-table=wallz : Legacy table to update}
|
||||
|
||||
Reference in New Issue
Block a user