This commit is contained in:
Egor Isaev 2026-08-06 15:45:09 +03:00
parent 4e41390544
commit 858e574e60
11 changed files with 457 additions and 20 deletions

View 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: проверка прав администратора, когда появится логин.
}
}

View 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(),
]);
}
}

View 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; ?>

View File

@ -8,4 +8,4 @@
?> ?>
<?php var_dump($created); ?> <?php var_dump($todo); ?>

41
App/view/layout.html Normal file
View 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>

View File

@ -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()` конкретных контроллеров (логина пока нет).

View File

@ -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;
} }
/** /**

View 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;
}
}

View 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;
}

View File

@ -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>

View 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);
}
}