From 858e574e606af84da391dbd3c996da93bafc78c1 Mon Sep 17 00:00:00 2001 From: Egor Isaev Date: Thu, 6 Aug 2026 15:45:09 +0300 Subject: [PATCH] dev --- App/Controller/Admin/AdminController.php | 32 ++++++++ App/Controller/Admin/LogsController.php | 44 +++++++++++ App/view/Admin/Logs/logs.html | 79 +++++++++++++++++++ App/view/Index/index.html | 2 +- App/view/layout.html | 41 ++++++++++ CLAUDE.md | 7 +- System/Classes/Controller.php | 32 ++++++-- System/Classes/FileLogReader.php | 98 +++++++++++++++++++++++ System/Classes/LogReader.php | 32 ++++++++ System/view/views/layout.html | 11 --- tests/Unit/FileLogReaderTest.php | 99 ++++++++++++++++++++++++ 11 files changed, 457 insertions(+), 20 deletions(-) create mode 100644 App/Controller/Admin/AdminController.php create mode 100644 App/Controller/Admin/LogsController.php create mode 100644 App/view/Admin/Logs/logs.html create mode 100644 App/view/layout.html create mode 100644 System/Classes/FileLogReader.php create mode 100644 System/Classes/LogReader.php delete mode 100644 System/view/views/layout.html create mode 100644 tests/Unit/FileLogReaderTest.php diff --git a/App/Controller/Admin/AdminController.php b/App/Controller/Admin/AdminController.php new file mode 100644 index 0000000..3f3165b --- /dev/null +++ b/App/Controller/Admin/AdminController.php @@ -0,0 +1,32 @@ + $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(), + ]); + } +} diff --git a/App/view/Admin/Logs/logs.html b/App/view/Admin/Logs/logs.html new file mode 100644 index 0000000..3fa9df4 --- /dev/null +++ b/App/view/Admin/Logs/logs.html @@ -0,0 +1,79 @@ + [ + 'error' => 'danger', + 'warning' => 'warning', + 'info' => 'primary', + 'debug' => 'secondary', +][$l] ?? 'secondary'; +?> +

Логи

+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + +
Записей нет.
+ + + + + + + + + + + + + + + + + + +
ВремяУровеньСообщение
+ diff --git a/App/view/Index/index.html b/App/view/Index/index.html index 175197c..ad16d76 100644 --- a/App/view/Index/index.html +++ b/App/view/Index/index.html @@ -8,4 +8,4 @@ ?> - + diff --git a/App/view/layout.html b/App/view/layout.html new file mode 100644 index 0000000..fd8aea8 --- /dev/null +++ b/App/view/layout.html @@ -0,0 +1,41 @@ + + + + + + + Bicycle + + + + + +
+ +
+ + + + diff --git a/CLAUDE.md b/CLAUDE.md index cfb7c9a..255a00b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -307,7 +307,8 @@ $response = HTTPException::factory(403)->getResponse(); ### View path convention - Шаблон контента: `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` - `$_paths` инициализируется лениво при первом вызове `findFile()` - `Controller::render()` автоматически определяет `$dir` из имени класса (`App\Controller\FooController` → `view/Foo`) @@ -317,11 +318,11 @@ $response = HTTPException::factory(403)->getResponse(); Расстановка имён — как в проекте eoffice_v3: - `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` внутри `
`). **Контроллеры приложения наследуют его.** 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()` конкретных контроллеров (логина пока нет). diff --git a/System/Classes/Controller.php b/System/Classes/Controller.php index 51ae3b5..7983c1a 100644 --- a/System/Classes/Controller.php +++ b/System/Classes/Controller.php @@ -17,7 +17,7 @@ use System\Classes\HTTP\Request as HTTPRequest; */ class Controller extends BaseController { - /** @var string Имя layout-шаблона в System/view/views */ + /** @var string Имя layout-шаблона в App/view (см. Core::findFile) */ protected string $_layout = 'layout'; /** @var bool Проверять ли CSRF-токен на небезопасных методах */ @@ -63,20 +63,42 @@ class Controller extends BaseController $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(), ] + $this->layoutData()))->render(); } /** - * Дополнительные данные, передаваемые в layout помимо 'content' - * (например меню). Переопределяется наследниками. По умолчанию пусто. + * Дополнительные данные, передаваемые в layout помимо 'content'. + * По умолчанию — пункты главного меню; наследники могут переопределить + * полностью (как AdminController — своим боковым меню). * * @return 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; } /** diff --git a/System/Classes/FileLogReader.php b/System/Classes/FileLogReader.php new file mode 100644 index 0000000..287d4b4 --- /dev/null +++ b/System/Classes/FileLogReader.php @@ -0,0 +1,98 @@ + $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; + } +} diff --git a/System/Classes/LogReader.php b/System/Classes/LogReader.php new file mode 100644 index 0000000..98421dc --- /dev/null +++ b/System/Classes/LogReader.php @@ -0,0 +1,32 @@ + + */ + public function read(array $filters = []): array; + + /** + * Возвращает доступные даты логов (Y-m-d), новые сверху — для выбора в фильтре. + * + * @return string[] + */ + public function dates(): array; +} diff --git a/System/view/views/layout.html b/System/view/views/layout.html deleted file mode 100644 index 1b016e2..0000000 --- a/System/view/views/layout.html +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - Bicycle - - - - - diff --git a/tests/Unit/FileLogReaderTest.php b/tests/Unit/FileLogReaderTest.php new file mode 100644 index 0000000..9f96d45 --- /dev/null +++ b/tests/Unit/FileLogReaderTest.php @@ -0,0 +1,99 @@ +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); + } +}