Log Viewer

A maintainer-only admin section for reading, searching, and deleting Laravel log files in storage/logs/. It is shipped as a self-contained domain module and does not require any new dependency.

Authorization

The whole module is gated on the system_admin role only — no entry exists in config/permission-resources.php. Non-system-admin users get a 403 on the route and never see the menu item, so the feature is invisible to them.

The route file routes/web/log-route.php is listed in the $routesWithoutPermissionMiddleware array in routes/web.php, which is why the dynamic check.permission middleware is skipped for this group.

Routes

The route file is loaded inside the authenticated web group. Named routes:

  • logs.index — GET /logs (Inertia)
  • logs.dtApi — GET /logs/dt (JSON datatable feed)
  • logs.show — GET /logs/{filename} (Inertia)
  • logs.entries — GET /logs/{filename}/entries (JSON, paginated)
  • logs.destroy — DELETE /logs (JSON, bulk delete)

Filename route parameters use where('filename', '[A-Za-z0-9._-]+\.log') so path traversal and non-.log requests never reach the controller.

UI

Two Inertia pages live under resources/js/pages/Admin/Logs/:

Index.vue

SkDatatable over logs.dtApi. Columns:

  • name — filename (sortable, search by substring)
  • channel_type — daily (matches laravel-YYYY-MM-DD.log), single (laravel.log), or other
  • size_bytes — formatted as KB / MB / GB
  • modified_at — relative timestamp with absolute tooltip
  • is_active — chip when the file is the live daily file or written within the last 5 seconds

Row action: Delete (disabled when is_active). Bulk select supports the Delete selected toolbar action; both flow through the logs.destroy endpoint and are gated by useConfirm.

Show.vue

Filter panel + paginated viewer for a single file. Filters:

  • levels[] — multi-select from the eight Laravel/PSR-3 levels (emergency, alert, critical, error, warning, notice, info, debug)
  • from, to — ISO date range
  • keyword — case-insensitive substring search across message + stack trace

Entries are listed newest first. Filter changes call logs.entries via useApi, replace the list, and reset the cursor. Load more uses next_cursor from the previous response to append the next older page. EOF flag short-circuits the button.

Each entry collapses to a level chip + timestamp + first part of the message. Expanding shows the full message, JSON-pretty-printed context (if any), and the stack trace.

Domain layer

The Logs domain is vendor-resident — its runtime layer runs from the package (src/Domain/Logs/, namespace Lvntr\StarterKit\Domain\Logs\) and is not scaffolded into your app. App\Domain\Logs\... imports keep working through class_alias.

src/Domain/Logs/   (Lvntr\StarterKit\Domain\Logs\)
├── DTOs/
│   ├── LogFileDTO.php          # name, path, size_bytes, modified_at, channel_type, is_active
│   ├── LogEntryDTO.php         # timestamp, level, env, message, context, stack, is_raw
│   ├── LogEntryFilterDTO.php   # levels, from, to, keyword, cursor, per_page
│   └── DeleteLogFilesDTO.php   # filenames[]
├── Queries/
│   ├── LogFileQuery.php        # lists storage/logs/, filters/sorts/paginates in-memory
│   └── LogEntryQuery.php       # streams a single file with cursor pagination
├── Actions/
│   └── DeleteLogFilesAction.php # bulk delete with active-file guardrails
├── Events/
│   └── LogFilesDeleted.php     # carries deleted filenames[] + causer id
├── Listeners/
│   └── LogActivityForLogFilesDeleted.php  # writes one spatie/activitylog entry per batch
└── Services/
    └── LaravelLogParser.php    # stateless line parser; multiline stack-trace aware

LogFilesDeleted → LogActivityForLogFilesDeleted is wired in StarterKitServiceProvider::registerEventListeners() (vendor), with both sides bound to the vendor FQCN so the dispatched event matches the registration key.

Streaming entry reader

LogEntryQuery::paginate() opens the file with fopen('rb') and walks lines with fgets() capped at 64KB per line. Because a log file grows at its end, a page is built backwards: the window ends at cursor (exclusive) and expands towards the start of the file in 64KB steps until it holds a full page, capped at 2MB per request. The window is then parsed forward (entries can only be read in write order) and emitted reversed. The cursor is the byte offset where the page's OLDEST entry begins, so the next page resumes with a single fseek. Memory stays bounded regardless of file size.

Unmatched lines are handled by position:

  • after a header — appended to the current entry's stack field, keeping multi-line exceptions intact
  • before any header (or in a file with no Laravel-format headers at all) — buffered and emitted as a single raw LogEntryDTO (is_raw = true, level = 'raw', sentinel epoch-0 timestamp). The UI hides the timestamp for raw entries and displays them with a gray chip, so the content stays visible instead of being silently dropped. Raw entries are filtered out the moment any structured filter (level / from / to / keyword) is applied.

Active-file guardrail

A file is considered "active" — and refused for deletion — when either is true:

  • it is today's daily file (laravel-{today}.log), or
  • its mtime is within the last 5 seconds (some other channel is currently appending).

Active files are reported back as failed[] entries with reason: 'active_file_protected' so partial bulk deletes still succeed for the rest.

Path-traversal guardrail

The safe filename regex ^[A-Za-z0-9._-]+\.log$ is enforced in three places: the route parameter constraint, the DeleteLogFilesRequest validation, and the DeleteLogFilesAction itself (defence in depth). Anything else returns log.invalid_filename.

Activity logging

When a delete batch succeeds (any deleted[] entries), LogFilesDeleted is dispatched. The listener writes one spatie/activitylog entry with log_name = 'system', no subject, the deleted filenames in properties.filenames, and the current user as causer. Entries surface in the existing Admin → Activity Logs page automatically.

i18n

All strings live in lang/en/sk-log.php and lang/tr/sk-log.php. The menu key (sk-menu.logs) is in the existing menu translation files. Failure reason codes returned by the action (invalid_filename, not_found, active_file_protected, delete_failed) map one-to-one onto sk-log.reason_* keys in the UI.

Wayfinder

Routes are typed: import logs from '@/routes/logs' exposes logs.index.url(), logs.show.url({ filename }), and so on. Frontend never hardcodes URLs.

Out of scope (v1)

Not implemented; open follow-up issues if you need them:

  • live tail / WebSocket streaming
  • cross-file search across all logs
  • time-based bulk purge ("delete files older than N days") — the daily channel handles this for you
  • .zip export / download
  • non-Laravel log formats (Apache, JSON channels)
  • per-user role gating beyond system_admin