app/Services/Uptime.php
Rama main · réplica de lectura del repositorio original.
<?php
namespace App\Services;
use App\Models\Component;
use App\Models\UptimeDay;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
/**
* Reads the daily roll-up rather than raw check results, so a 90-day bar costs
* 90 rows per component instead of 129,600.
*/
class Uptime
{
public const WINDOW_DAYS = 90;
/**
* @return array<int, array{day: string, tone: string, pct: float, known: bool}>
* Oldest first, always exactly WINDOW_DAYS entries.
*/
public function bar(Component $component, ?Carbon $today = null): array
{
return $this->barsFor([$component], $today)[$component->id];
}
/**
* One query for every component on a page instead of one per component.
*
* @param iterable<Component> $components
* @return array<int, array<int, array{day: string, tone: string, pct: float, known: bool}>> keyed by component id
*/
public function barsFor(iterable $components, ?Carbon $today = null): array
{
$today ??= Carbon::today();
$start = $today->copy()->subDays(self::WINDOW_DAYS - 1);
$ids = array_map(fn (Component $c) => $c->id, is_array($components) ? $components : iterator_to_array($components));
$rows = UptimeDay::whereIn('component_id', $ids)
// Carbon bounds, not strings: the date cast stores "Y-m-d 00:00:00",
// which sorts after a bare "Y-m-d" and silently dropped today.
->whereBetween('day', [$start->copy()->startOfDay(), $today->copy()->endOfDay()])
->get()
->groupBy('component_id');
$bars = [];
foreach ($ids as $id) {
$bars[$id] = $this->build($rows->get($id, collect())->keyBy(fn ($r) => $r->day->format('Y-m-d')), $start);
}
return $bars;
}
/** @param Collection<string, UptimeDay> $rows */
protected function build($rows, Carbon $start): array
{
$bar = [];
for ($i = 0; $i < self::WINDOW_DAYS; $i++) {
$key = $start->copy()->addDays($i)->format('Y-m-d');
$row = $rows->get($key);
$known = $row && ($row->up_seconds + $row->down_seconds) > 0;
$bar[] = [
'day' => $key,
'known' => $known,
'pct' => $known ? $row->percentage() : 0.0,
'tone' => $known ? $this->tone($row->percentage()) : 'unknown',
];
}
return $bar;
}
/** Uptime over the window as a percentage, days without data excluded. */
public function percentage(Component $component, ?Carbon $today = null): ?float
{
return $this->percentageOf($this->bar($component, $today));
}
/** The same figure from a bar that is already in hand, so the page asks once. */
public function percentageOf(array $bar): ?float
{
$known = array_filter($bar, fn ($d) => $d['known']);
if ($known === []) {
return null;
}
return round(array_sum(array_column($known, 'pct')) / count($known), 2);
}
public static function format(?float $percentage): string
{
return $percentage === null ? 'No data' : number_format($percentage, 2).'%';
}
/** Equal weight per component with observations; unknown components are excluded. */
public static function average(iterable $percentages): ?float
{
$known = collect($percentages)->filter(fn ($value) => $value !== null);
return $known->isEmpty() ? null : round($known->avg(), 2);
}
/** Aggregate known component-days, keeping unmeasured days visibly unknown. */
public function aggregate(array $bars): array
{
if ($bars === []) {
return [];
}
$result = [];
foreach (reset($bars) as $index => $day) {
$pct = self::average(array_map(fn ($bar) => $bar[$index]['known'] ? $bar[$index]['pct'] : null, $bars));
$result[] = ['day' => $day['day'], 'known' => $pct !== null, 'pct' => $pct ?? 0.0, 'tone' => $pct === null ? 'unknown' : $this->tone($pct)];
}
return $result;
}
protected function tone(float $pct): string
{
return match (true) {
$pct >= 99.99 => 'ok',
$pct >= 99.0 => 'w',
$pct >= 95.0 => 'p',
default => 'b',
};
}
}