Files
coruna-lab/app/Http/Controllers/Admin/RawLogController.php
T
2026-08-14 16:09:41 +08:00

210 lines
6.3 KiB
PHP

<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\File;
class RawLogController extends Controller
{
/** @var list<string> */
private const KINDS = ['c2', 'xxbb'];
private const PER_PAGE = 40;
private const MAX_PER_PAGE = 100;
/** Display cap so a single keystore/census blob cannot blow the HTML layout. */
private const MAX_ENTRY_CHARS = 16384;
private const CHUNK_BYTES = 262144;
/** Bound the backward-scan carry if a record has no separator. */
private const MAX_CARRY_BYTES = 524288;
public function index(Request $request)
{
$kind = $this->kind($request);
$path = trim((string) $request->query('path', ''));
$device = trim((string) $request->query('device', ''));
$groups = [];
foreach (self::KINDS as $k) {
$groups[$k] = $this->listFiles($k);
}
return view('admin.logs.index', compact('groups', 'kind', 'path', 'device'));
}
public function show(Request $request, string $kind, string $file)
{
$kind = in_array($kind, self::KINDS, true) ? $kind : 'c2';
$safe = basename($file);
if (! preg_match('/^\d{8}\.log$/', $safe)) {
abort(404);
}
$full = public_path('log/'.$kind.'/'.$safe);
abort_unless(is_file($full), 404);
$path = trim((string) $request->query('path', ''));
$device = trim((string) $request->query('device', ''));
$page = max(1, (int) $request->query('page', 1));
$perPage = (int) $request->query('per_page', self::PER_PAGE);
$perPage = min(self::MAX_PER_PAGE, max(10, $perPage));
$read = $this->tailEntries($full, $path, $device, $page, $perPage);
return view('admin.logs.show', [
'kind' => $kind,
'file' => $safe,
'entries' => $read['entries'],
'path' => $path,
'device' => $device,
'page' => $read['page'],
'perPage' => $read['perPage'],
'hasOlder' => $read['hasOlder'],
'hasNewer' => $read['hasNewer'],
'fileSize' => $read['fileSize'],
]);
}
private function kind(Request $request): string
{
$kind = (string) $request->query('kind', 'c2');
return in_array($kind, self::KINDS, true) ? $kind : 'c2';
}
/**
* Newest-first page of records, scanning backward so a multi-MB day file
* never lands in memory as one string.
*
* @return array{
* entries: list<string>,
* page: int,
* perPage: int,
* hasOlder: bool,
* hasNewer: bool,
* fileSize: int
* }
*/
private function tailEntries(string $full, string $path, string $device, int $page, int $perPage): array
{
$fileSize = (int) filesize($full);
$skip = ($page - 1) * $perPage;
$want = $skip + $perPage + 1;
$matched = [];
$fh = fopen($full, 'rb');
if ($fh === false) {
return [
'entries' => [],
'page' => $page,
'perPage' => $perPage,
'hasOlder' => false,
'hasNewer' => $page > 1,
'fileSize' => $fileSize,
];
}
try {
$pos = $fileSize;
$carry = '';
while ($pos > 0 && count($matched) < $want) {
$read = (int) min(self::CHUNK_BYTES, $pos);
$pos -= $read;
if (fseek($fh, $pos) !== 0) {
break;
}
$chunk = fread($fh, $read);
if (! is_string($chunk) || $chunk === '') {
break;
}
$data = $chunk.$carry;
if (strlen($data) > self::MAX_CARRY_BYTES) {
$data = substr($data, -self::MAX_CARRY_BYTES);
}
$parts = preg_split("/\r\n\r\n/", $data) ?: [$data];
$carry = $parts[0];
for ($i = count($parts) - 1; $i >= 1; $i--) {
if ($this->collectEntry($matched, $parts[$i], $path, $device, $want)) {
break 2;
}
}
}
if (count($matched) < $want && $pos === 0 && $carry !== '') {
$this->collectEntry($matched, $carry, $path, $device, $want);
}
} finally {
fclose($fh);
}
$hasOlder = count($matched) > $skip + $perPage;
$entries = array_slice($matched, $skip, $perPage);
return [
'entries' => $entries,
'page' => $page,
'perPage' => $perPage,
'hasOlder' => $hasOlder,
'hasNewer' => $page > 1,
'fileSize' => $fileSize,
];
}
/**
* @param list<string> $matched
*/
private function collectEntry(array &$matched, string $chunk, string $path, string $device, int $want): bool
{
$entry = trim($chunk);
if ($entry === '') {
return false;
}
if ($path !== '' && ! str_contains($entry, $path)) {
return false;
}
if ($device !== '' && ! str_contains($entry, $device)) {
return false;
}
$matched[] = $this->truncateEntry($entry);
return count($matched) >= $want;
}
private function truncateEntry(string $entry): string
{
if (strlen($entry) <= self::MAX_ENTRY_CHARS) {
return $entry;
}
return substr($entry, 0, self::MAX_ENTRY_CHARS)."\n… (truncated)";
}
/**
* @return list<array{name: string, size: int, mtime: string}>
*/
private function listFiles(string $kind): array
{
$dir = public_path('log/'.$kind);
$files = [];
if (! is_dir($dir)) {
return $files;
}
foreach (File::files($dir) as $file) {
if ($file->getExtension() !== 'log') {
continue;
}
$files[] = [
'name' => $file->getFilename(),
'size' => $file->getSize(),
'mtime' => date('Y-m-d H:i:s', $file->getMTime()),
];
}
usort($files, fn ($a, $b) => strcmp($b['name'], $a['name']));
return $files;
}
}