dev
This commit is contained in:
parent
4e41390544
commit
858e574e60
32
App/Controller/Admin/AdminController.php
Normal file
32
App/Controller/Admin/AdminController.php
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package Bicycle
|
||||||
|
* @author Egor Isaev
|
||||||
|
* @description AdminController.php
|
||||||
|
* @copyright (c) 24/06/2026
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Controller\Admin;
|
||||||
|
|
||||||
|
use System\Classes\Controller;
|
||||||
|
use System\Classes\HTTP\HTTPException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Базовый контроллер админки. Рендерится в общем layout сайта
|
||||||
|
* (App/view/layout.html), своего отдельного шаблона не имеет.
|
||||||
|
* Конкретные страницы (Logs и т.п.) наследуют его.
|
||||||
|
*/
|
||||||
|
abstract class AdminController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Точка для проверки прав администратора.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
* @throws HTTPException
|
||||||
|
*/
|
||||||
|
protected function before(): void
|
||||||
|
{
|
||||||
|
parent::before(); // авто-CSRF на небезопасных методах
|
||||||
|
// TODO: проверка прав администратора, когда появится логин.
|
||||||
|
}
|
||||||
|
}
|
||||||
44
App/Controller/Admin/LogsController.php
Normal file
44
App/Controller/Admin/LogsController.php
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package Bicycle
|
||||||
|
* @author Egor Isaev
|
||||||
|
* @description LogsController.php
|
||||||
|
* @copyright (c) 24/06/2026
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace App\Controller\Admin;
|
||||||
|
|
||||||
|
use System\Classes\FileLogReader;
|
||||||
|
use System\Classes\Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Просмотр и поиск логов. URL: /admin/logs
|
||||||
|
*/
|
||||||
|
class LogsController extends AdminController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Список записей лога с фильтрами (дата, канал, уровень, текст).
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
* @throws \System\Classes\MyException
|
||||||
|
*/
|
||||||
|
public function indexAction(): string
|
||||||
|
{
|
||||||
|
$request = Request::$current;
|
||||||
|
// Позже FileLogReader заменяется на DbLogReader без изменения остального.
|
||||||
|
$reader = new FileLogReader();
|
||||||
|
|
||||||
|
$filters = [
|
||||||
|
'date' => $request?->query('date') ?: date('Y-m-d'),
|
||||||
|
'channel' => $request?->query('channel') ?: 'action',
|
||||||
|
'level' => $request?->query('level') ?: '',
|
||||||
|
'q' => $request?->query('q') ?: '',
|
||||||
|
];
|
||||||
|
|
||||||
|
return $this->render('logs', [
|
||||||
|
'entries' => $reader->read($filters),
|
||||||
|
'filters' => $filters,
|
||||||
|
'dates' => $reader->dates(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
79
App/view/Admin/Logs/logs.html
Normal file
79
App/view/Admin/Logs/logs.html
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package Bicycle
|
||||||
|
* @description logs.html — просмотр и поиск логов
|
||||||
|
*
|
||||||
|
* @var array $entries Записи лога [['datetime','level','message','channel'], ...]
|
||||||
|
* @var array $filters Текущие фильтры [date, channel, level, q]
|
||||||
|
* @var array $dates Доступные даты (Y-m-d)
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Цвет badge по уровню.
|
||||||
|
$level_class = static fn(string $l): string => [
|
||||||
|
'error' => 'danger',
|
||||||
|
'warning' => 'warning',
|
||||||
|
'info' => 'primary',
|
||||||
|
'debug' => 'secondary',
|
||||||
|
][$l] ?? 'secondary';
|
||||||
|
?>
|
||||||
|
<h1 class="h3 mb-3">Логи</h1>
|
||||||
|
|
||||||
|
<form method="get" class="row g-2 mb-3">
|
||||||
|
<div class="col-auto">
|
||||||
|
<select name="date" class="form-select">
|
||||||
|
<?php if (!in_array($filters['date'], $dates, true)): ?>
|
||||||
|
<option value="<?= htmlspecialchars($filters['date'], ENT_QUOTES) ?>" selected>
|
||||||
|
<?= htmlspecialchars($filters['date'], ENT_QUOTES) ?>
|
||||||
|
</option>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php foreach ($dates as $d): ?>
|
||||||
|
<option value="<?= $d ?>"<?= $d === $filters['date'] ? ' selected' : '' ?>><?= $d ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<select name="channel" class="form-select">
|
||||||
|
<option value="action"<?= $filters['channel'] === 'action' ? ' selected' : '' ?>>Действия</option>
|
||||||
|
<option value="error"<?= $filters['channel'] === 'error' ? ' selected' : '' ?>>Ошибки</option>
|
||||||
|
<option value="all"<?= $filters['channel'] === 'all' ? ' selected' : '' ?>>Все</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<select name="level" class="form-select">
|
||||||
|
<option value="">Все уровни</option>
|
||||||
|
<?php foreach (['debug', 'info', 'warning', 'error'] as $lvl): ?>
|
||||||
|
<option value="<?= $lvl ?>"<?= $filters['level'] === $lvl ? ' selected' : '' ?>><?= $lvl ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<input type="text" name="q" class="form-control" placeholder="Поиск по тексту"
|
||||||
|
value="<?= htmlspecialchars($filters['q'], ENT_QUOTES) ?>">
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<button type="submit" class="btn btn-primary">Найти</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<?php if (empty($entries)): ?>
|
||||||
|
<div class="alert alert-secondary">Записей нет.</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<table class="table table-sm table-striped align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="text-nowrap">Время</th>
|
||||||
|
<th>Уровень</th>
|
||||||
|
<th>Сообщение</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($entries as $e): ?>
|
||||||
|
<tr>
|
||||||
|
<td class="text-nowrap"><?= htmlspecialchars($e['datetime'], ENT_QUOTES) ?></td>
|
||||||
|
<td><span class="badge bg-<?= $level_class($e['level']) ?>"><?= htmlspecialchars($e['level'], ENT_QUOTES) ?></span></td>
|
||||||
|
<td><?= htmlspecialchars($e['message'], ENT_QUOTES, 'UTF-8') ?></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<?php endif; ?>
|
||||||
@ -8,4 +8,4 @@
|
|||||||
|
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<?php var_dump($created); ?>
|
<?php var_dump($todo); ?>
|
||||||
|
|||||||
41
App/view/layout.html
Normal file
41
App/view/layout.html
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package Bicycle
|
||||||
|
* @description layout.html — главный шаблон сайта с верхним меню
|
||||||
|
*
|
||||||
|
* @var string $content Содержимое страницы
|
||||||
|
* @var array $menu Пункты меню [['title','url','active'], ...]
|
||||||
|
*/
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Bicycle</title>
|
||||||
|
<link rel="stylesheet" href="/vendor/twbs/bootstrap/dist/css/bootstrap.min.css">
|
||||||
|
<link rel="stylesheet" href="/vendor/twbs/bootstrap-icons/font/bootstrap-icons.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="navbar navbar-expand navbar-light bg-light border-bottom">
|
||||||
|
<div class="container">
|
||||||
|
<a class="navbar-brand" href="/">Bicycle</a>
|
||||||
|
<ul class="navbar-nav">
|
||||||
|
<?php foreach (($menu ?? []) as $item): ?>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link<?= !empty($item['active']) ? ' active fw-bold' : '' ?>"
|
||||||
|
href="<?= htmlspecialchars($item['url'], ENT_QUOTES, 'UTF-8') ?>">
|
||||||
|
<?= htmlspecialchars($item['title'], ENT_QUOTES, 'UTF-8') ?>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<main class="container py-3">
|
||||||
|
<?= $content ?>
|
||||||
|
</main>
|
||||||
|
<script src="/vendor/components/jquery/jquery.min.js"></script>
|
||||||
|
<script src="/vendor/twbs/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -307,7 +307,8 @@ $response = HTTPException::factory(403)->getResponse();
|
|||||||
### View path convention
|
### View path convention
|
||||||
|
|
||||||
- Шаблон контента: `App/view/{Controller}/{template}.html`
|
- Шаблон контента: `App/view/{Controller}/{template}.html`
|
||||||
- Layout: `System/view/views/{layout}.html`
|
- Layout: `App/view/views/{layout}.html` (app-специфичные шаблоны — не в `System`, там только нейтральные fallback-страницы вроде `errors/`, `exception/`)
|
||||||
|
- Общие CSS/JS-подключения (Bootstrap, Bootstrap Icons, jQuery) вынесены в партиалы `App/view/views/_styles.html` и `_scripts.html` — layout'ы (`layout.html`, `admin.html`) инклудят их через `(new View('_styles', 'view/views'))->render()`, чтобы не дублировать теги при появлении новых layout'ов
|
||||||
- `Core::findFile($dir, $file, 'html')` ищет в `APPPATH/{dir}/{file}.html`, затем в `SYSPATH/{dir}/{file}.html`
|
- `Core::findFile($dir, $file, 'html')` ищет в `APPPATH/{dir}/{file}.html`, затем в `SYSPATH/{dir}/{file}.html`
|
||||||
- `$_paths` инициализируется лениво при первом вызове `findFile()`
|
- `$_paths` инициализируется лениво при первом вызове `findFile()`
|
||||||
- `Controller::render()` автоматически определяет `$dir` из имени класса (`App\Controller\FooController` → `view/Foo`)
|
- `Controller::render()` автоматически определяет `$dir` из имени класса (`App\Controller\FooController` → `view/Foo`)
|
||||||
@ -317,11 +318,11 @@ $response = HTTPException::factory(403)->getResponse();
|
|||||||
Расстановка имён — как в проекте eoffice_v3:
|
Расстановка имён — как в проекте eoffice_v3:
|
||||||
|
|
||||||
- `System\Classes\BaseController` (abstract) — голое ядро: жизненный цикл `executeAction()` (`before()` → экшен → `after()`), хуки по умолчанию пустые. Нейтрально к вебу/API.
|
- `System\Classes\BaseController` (abstract) — голое ядро: жизненный цикл `executeAction()` (`before()` → экшен → `after()`), хуки по умолчанию пустые. Нейтрально к вебу/API.
|
||||||
- `System\Classes\Controller extends BaseController` — веб: `render()` (layout + content), авто-CSRF в `before()`, `json($data, $status)` для JSON-ответов, хук `layoutData()` (доп. данные в layout помимо `content`). **Контроллеры приложения наследуют его.**
|
- `System\Classes\Controller extends BaseController` — веб: `render()` (layout + content), авто-CSRF в `before()`, `json($data, $status)` для JSON-ответов, хуки `layoutData()` (доп. данные в layout помимо `content`; по умолчанию `['menu' => $this->menu()]`) и `menu()` (пункты главного меню сайта, активный — по текущему URI). Layout по умолчанию — `App/view/views/layout.html` (Bootstrap-навбар сверху, `$content` внутри `<main>`). **Контроллеры приложения наследуют его.**
|
||||||
|
|
||||||
API-контроллер делается не отдельным классом, а флагом: `extends Controller` + `$_csrf_protection = false` + ответы через `json()` (так же, как в eoffice_v3).
|
API-контроллер делается не отдельным классом, а флагом: `extends Controller` + `$_csrf_protection = false` + ответы через `json()` (так же, как в eoffice_v3).
|
||||||
|
|
||||||
**Админка** (`App/Controller/Admin/`): базовый `App\Controller\Admin\AdminController extends Controller` задаёт layout `admin` (боковое меню, `System/view/views/admin.html`, Bootstrap из `/vendor/`) и наполняет `layoutData()` пунктами меню (`menu()` — пока статичный список, задел под БД). `before()` — заготовка под проверку прав (логина пока нет, **админка открыта**). Конкретные страницы наследуют `AdminController`.
|
**Админка** (`App/Controller/Admin/`): базовый `App\Controller\Admin\AdminController extends Controller` задаёт свой layout `admin` (боковое меню, `App/view/views/admin.html`, Bootstrap из `/vendor/`) и переопределяет `layoutData()`/`menu()` под собственный список пунктов (пока статичный, задел под БД). `before()` — заготовка под проверку прав (логина пока нет, **админка открыта**). Конкретные страницы наследуют `AdminController`.
|
||||||
|
|
||||||
`Request::execute()` вызывает `executeAction({action}Action)`, поэтому `before()/after()` работают для любого контроллера прозрачно. Авторизацию добавлять в `before()` конкретных контроллеров (логина пока нет).
|
`Request::execute()` вызывает `executeAction({action}Action)`, поэтому `before()/after()` работают для любого контроллера прозрачно. Авторизацию добавлять в `before()` конкретных контроллеров (логина пока нет).
|
||||||
|
|
||||||
|
|||||||
@ -17,7 +17,7 @@ use System\Classes\HTTP\Request as HTTPRequest;
|
|||||||
*/
|
*/
|
||||||
class Controller extends BaseController
|
class Controller extends BaseController
|
||||||
{
|
{
|
||||||
/** @var string Имя layout-шаблона в System/view/views */
|
/** @var string Имя layout-шаблона в App/view (см. Core::findFile) */
|
||||||
protected string $_layout = 'layout';
|
protected string $_layout = 'layout';
|
||||||
|
|
||||||
/** @var bool Проверять ли CSRF-токен на небезопасных методах */
|
/** @var bool Проверять ли CSRF-токен на небезопасных методах */
|
||||||
@ -63,20 +63,42 @@ class Controller extends BaseController
|
|||||||
$dir = 'view/' . str_replace('\\', '/', substr($class, 0, -10)); // view/[Admin/]Foo
|
$dir = 'view/' . str_replace('\\', '/', substr($class, 0, -10)); // view/[Admin/]Foo
|
||||||
}
|
}
|
||||||
|
|
||||||
return (new View($this->_layout, 'view/views', [
|
return (new View($this->_layout, 'view', [
|
||||||
'content' => (new View($template, $dir, $data))->render(),
|
'content' => (new View($template, $dir, $data))->render(),
|
||||||
] + $this->layoutData()))->render();
|
] + $this->layoutData()))->render();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Дополнительные данные, передаваемые в layout помимо 'content'
|
* Дополнительные данные, передаваемые в layout помимо 'content'.
|
||||||
* (например меню). Переопределяется наследниками. По умолчанию пусто.
|
* По умолчанию — пункты главного меню; наследники могут переопределить
|
||||||
|
* полностью (как AdminController — своим боковым меню).
|
||||||
*
|
*
|
||||||
* @return array
|
* @return array
|
||||||
*/
|
*/
|
||||||
protected function layoutData(): array
|
protected function layoutData(): array
|
||||||
{
|
{
|
||||||
return [];
|
return ['menu' => $this->menu()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Пункты главного меню сайта. Активный пункт — по текущему URI.
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
protected function menu(): array
|
||||||
|
{
|
||||||
|
$uri = Request::$current?->uri() ?? '';
|
||||||
|
$items = [
|
||||||
|
['title' => 'Главная', 'url' => '/'],
|
||||||
|
['title' => 'Обратная связь', 'url' => '/feedback'],
|
||||||
|
['title' => 'Админка', 'url' => '/admin/logs'],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($items as &$item) {
|
||||||
|
$item['active'] = trim($item['url'], '/') === $uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $items;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
98
System/Classes/FileLogReader.php
Normal file
98
System/Classes/FileLogReader.php
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package Bicycle
|
||||||
|
* @author Egor Isaev
|
||||||
|
* @description FileLogReader.php
|
||||||
|
* @copyright (c) 24/06/2026
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace System\Classes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Чтение логов из файлов {channel}-{Y-m-d}.log в каталоге Log::directory().
|
||||||
|
* Парсит строки формата "[Y-m-d H:i:s] LEVEL: message".
|
||||||
|
*/
|
||||||
|
class FileLogReader implements LogReader
|
||||||
|
{
|
||||||
|
/** @var int Максимум записей в выдаче */
|
||||||
|
public int $limit = 1000;
|
||||||
|
|
||||||
|
/** Регулярка строки лога: дата-время, уровень, сообщение */
|
||||||
|
private const LINE = '/^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\] (\w+): (.*)$/';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
public function read(array $filters = []): array
|
||||||
|
{
|
||||||
|
$date = $filters['date'] ?? date('Y-m-d');
|
||||||
|
$channel = $filters['channel'] ?? 'action';
|
||||||
|
$level = strtolower((string)($filters['level'] ?? ''));
|
||||||
|
$q = (string)($filters['q'] ?? '');
|
||||||
|
|
||||||
|
$channels = $channel === 'all' ? ['action', 'error'] : [$channel];
|
||||||
|
$entries = [];
|
||||||
|
|
||||||
|
foreach ($channels as $ch) {
|
||||||
|
$file = Log::directory() . '/' . $ch . '-' . $date . '.log';
|
||||||
|
if (!is_file($file)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
|
||||||
|
if (!preg_match(self::LINE, $line, $m)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$entry = [
|
||||||
|
'datetime' => $m[1],
|
||||||
|
'level' => strtolower($m[2]),
|
||||||
|
'message' => $m[3],
|
||||||
|
'channel' => $ch,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($level !== '' && $entry['level'] !== $level) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($q !== '' && stripos($line, $q) === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$entries[] = $entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Новые сверху (datetime сравним лексикографически).
|
||||||
|
usort($entries, static fn($a, $b) => strcmp($b['datetime'], $a['datetime']));
|
||||||
|
|
||||||
|
return array_slice($entries, 0, $this->limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
public function dates(): array
|
||||||
|
{
|
||||||
|
$dir = Log::directory();
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$files = array_merge(
|
||||||
|
glob($dir . '/action-*.log') ?: [],
|
||||||
|
glob($dir . '/error-*.log') ?: []
|
||||||
|
);
|
||||||
|
|
||||||
|
$dates = [];
|
||||||
|
foreach ($files as $file) {
|
||||||
|
if (preg_match('/-(\d{4}-\d{2}-\d{2})\.log$/', $file, $m)) {
|
||||||
|
$dates[$m[1]] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$dates = array_keys($dates);
|
||||||
|
rsort($dates); // новые даты сверху
|
||||||
|
|
||||||
|
return $dates;
|
||||||
|
}
|
||||||
|
}
|
||||||
32
System/Classes/LogReader.php
Normal file
32
System/Classes/LogReader.php
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* @package Bicycle
|
||||||
|
* @author Egor Isaev
|
||||||
|
* @description LogReader.php
|
||||||
|
* @copyright (c) 24/06/2026
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace System\Classes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Источник логов для просмотра. Абстракция над хранилищем: сейчас FileLogReader
|
||||||
|
* (файлы), позже возможен DbLogReader (БД) — контроллер от реализации не зависит.
|
||||||
|
*/
|
||||||
|
interface LogReader
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Возвращает записи лога, новые сверху.
|
||||||
|
*
|
||||||
|
* @param array $filters Фильтры: date (Y-m-d), channel (action|error|all),
|
||||||
|
* level, q (подстрока), limit
|
||||||
|
* @return array<int,array{datetime:string,level:string,message:string,channel:string}>
|
||||||
|
*/
|
||||||
|
public function read(array $filters = []): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Возвращает доступные даты логов (Y-m-d), новые сверху — для выбора в фильтре.
|
||||||
|
*
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
public function dates(): array;
|
||||||
|
}
|
||||||
@ -1,11 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="ru">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Bicycle</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<?= $content ?>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
99
tests/Unit/FileLogReaderTest.php
Normal file
99
tests/Unit/FileLogReaderTest.php
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use System\Classes\FileLogReader;
|
||||||
|
use System\Classes\Log;
|
||||||
|
|
||||||
|
class FileLogReaderTest extends TestCase
|
||||||
|
{
|
||||||
|
private string $dir;
|
||||||
|
private string $today;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->dir = sys_get_temp_dir() . '/bicycle_reader_' . uniqid();
|
||||||
|
$this->today = date('Y-m-d');
|
||||||
|
mkdir($this->dir);
|
||||||
|
Log::$directory = $this->dir;
|
||||||
|
|
||||||
|
file_put_contents($this->dir . '/action-' . $this->today . '.log',
|
||||||
|
"[{$this->today} 10:00:00] INFO: GET /feedback Feedback::index\n" .
|
||||||
|
"[{$this->today} 10:01:00] DEBUG: какой-то debug\n"
|
||||||
|
);
|
||||||
|
file_put_contents($this->dir . '/error-' . $this->today . '.log',
|
||||||
|
"[{$this->today} 10:02:00] ERROR: что-то сломалось\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
foreach (glob($this->dir . '/*') as $file) {
|
||||||
|
unlink($file);
|
||||||
|
}
|
||||||
|
rmdir($this->dir);
|
||||||
|
Log::$directory = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testParsesEntries(): void
|
||||||
|
{
|
||||||
|
$entries = (new FileLogReader())->read(['channel' => 'action', 'date' => $this->today]);
|
||||||
|
|
||||||
|
$this->assertCount(2, $entries);
|
||||||
|
$first = $entries[0];
|
||||||
|
$this->assertArrayHasKey('datetime', $first);
|
||||||
|
$this->assertArrayHasKey('level', $first);
|
||||||
|
$this->assertArrayHasKey('message', $first);
|
||||||
|
$this->assertSame('action', $first['channel']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNewestFirst(): void
|
||||||
|
{
|
||||||
|
$entries = (new FileLogReader())->read(['channel' => 'action', 'date' => $this->today]);
|
||||||
|
// 10:01 (debug) должно быть выше 10:00 (info)
|
||||||
|
$this->assertSame('debug', $entries[0]['level']);
|
||||||
|
$this->assertSame('info', $entries[1]['level']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFilterByLevel(): void
|
||||||
|
{
|
||||||
|
$entries = (new FileLogReader())->read(['channel' => 'action', 'date' => $this->today, 'level' => 'info']);
|
||||||
|
$this->assertCount(1, $entries);
|
||||||
|
$this->assertSame('info', $entries[0]['level']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFilterByText(): void
|
||||||
|
{
|
||||||
|
$entries = (new FileLogReader())->read(['channel' => 'action', 'date' => $this->today, 'q' => 'feedback']);
|
||||||
|
$this->assertCount(1, $entries);
|
||||||
|
$this->assertStringContainsString('feedback', $entries[0]['message']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testErrorChannel(): void
|
||||||
|
{
|
||||||
|
$entries = (new FileLogReader())->read(['channel' => 'error', 'date' => $this->today]);
|
||||||
|
$this->assertCount(1, $entries);
|
||||||
|
$this->assertSame('error', $entries[0]['level']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAllChannelsCombined(): void
|
||||||
|
{
|
||||||
|
$entries = (new FileLogReader())->read(['channel' => 'all', 'date' => $this->today]);
|
||||||
|
$this->assertCount(3, $entries);
|
||||||
|
// Новейшая запись — ошибка в 10:02.
|
||||||
|
$this->assertSame('error', $entries[0]['level']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMissingFileReturnsEmpty(): void
|
||||||
|
{
|
||||||
|
$entries = (new FileLogReader())->read(['channel' => 'action', 'date' => '2000-01-01']);
|
||||||
|
$this->assertSame([], $entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDatesListsAvailable(): void
|
||||||
|
{
|
||||||
|
$dates = (new FileLogReader())->dates();
|
||||||
|
$this->assertContains($this->today, $dates);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user