admin: errors interface

This commit is contained in:
E. S. 2024-03-13 21:50:36 +00:00
parent ca5b49205e
commit c603dec3ca
16 changed files with 528 additions and 6 deletions

View File

@ -230,11 +230,11 @@ class DatabaseLogger extends Logger {
}
}
function getPHPErrorName(int $errno): string {
function getPHPErrorName(int $errno): ?string {
static $errors = null;
if (is_null($errors))
$errors = array_flip(array_slice(get_defined_constants(true)['Core'], 0, 15, true));
return $errors[$errno];
return $errors[$errno] ?? null;
}
function strVarDump($var, bool $print_r = false): string {

View File

@ -224,3 +224,14 @@ function csrf_check(string $key) {
if (csrf_get($key) != ($_REQUEST['token'] ?? ''))
forbidden('invalid token');
}
function get_page(int $per_page, ?int $count = null): array {
list($page) = input('i:page');
$pages = $count !== null ? ceil($count / $per_page) : null;
if ($pages !== null && $page > $pages)
$page = $pages;
if ($page < 1)
$page = 1;
$offset = $per_page * ($page-1);
return [$page, $pages, $offset];
}

View File

@ -1,6 +1,6 @@
<?php
const ROUTER_VERSION = 4;
const ROUTER_VERSION = 5;
const ROUTER_MC_KEY = '4in1/routes';
$RouterInput = [];

View File

@ -144,6 +144,7 @@ class SkinContext {
'urlencoded' => SkinStringModificationType::URL,
'jsonencoded' => SkinStringModificationType::JSON,
'addslashes' => SkinStringModificationType::ADDSLASHES,
'nl2br' => SkinStringModificationType::NL2BR,
default => SkinStringModificationType::HTML
};
} else {
@ -222,6 +223,69 @@ class SkinContext {
return '<div class="'.$class.'"'.($style ? ' style="'.$style.'"' : '').'>'.$buf.'</div>';
}
function pagenav(int $page, int $pages, string $link_template, ?array $opts = null) {
if ($opts === null) {
$count = 0;
} else {
$opts = array_merge([
'count' => 0,
], $opts);
$count = $opts['count'];
}
$min_page = max(1, $page-2);
$max_page = min($pages, $page+2);
$pages_html = '';
$base_class = 'pn-button no-hover no-select no-drag is-page';
for ($p = $min_page; $p <= $max_page; $p++) {
$class = $base_class;
if ($p == $page) {
$class .= ' is-page-cur';
}
$pages_html .= '<a class="'.$class.'" href="'.htmlescape(self::_page_nav_get_link($p, $link_template)).'" data-page="'.$p.'" draggable="false">'.$p.'</a>';
}
if ($min_page > 2) {
$pages_html = '<div class="pn-button-sep no-select no-drag">&nbsp;</div>'.$pages_html;
}
if ($min_page > 1) {
$pages_html = '<a class="'.$base_class.'" href="'.htmlescape(self::_page_nav_get_link(1, $link_template)).'" data-page="1" draggable="false">1</a>'.$pages_html;
}
if ($max_page < $pages-1) {
$pages_html .= '<div class="pn-button-sep no-select no-drag">&nbsp;</div>';
}
if ($max_page < $pages) {
$pages_html .= '<a class="'.$base_class.'" href="'.htmlescape(self::_page_nav_get_link($pages, $link_template)).'" data-page="'.$pages.'" draggable="false">'.$pages.'</a>';
}
$pn_class = 'pn';
if ($pages < 2) {
$pn_class .= ' no-nav';
if (!$count) {
$pn_class .= ' no-results';
}
}
$html = <<<HTML
<div class="{$pn_class}">
<div class="pn-buttons clearfix">
{$pages_html}
</div>
</div>
HTML;
return $html;
}
protected static function _page_nav_get_link($page, $link_template) {
return is_callable($link_template)
? $link_template($page)
: str_replace('{page}', $page, $link_template);
}
protected function _if_condition($condition, $callback, ...$args) {
if (is_string($condition) || $condition instanceof Stringable)
$condition = (string)$condition !== '';
@ -339,6 +403,7 @@ enum SkinStringModificationType {
case HTML;
case JSON;
case ADDSLASHES;
case NL2BR;
}
class SkinString implements Stringable {
@ -353,6 +418,7 @@ class SkinString implements Stringable {
SkinStringModificationType::URL => urlencode($this->string),
SkinStringModificationType::JSON => jsonEncode($this->string),
SkinStringModificationType::ADDSLASHES => addslashes($this->string),
SkinStringModificationType::NL2BR => nl2br(htmlescape($this->string)),
default => $this->string,
};
}

View File

@ -368,3 +368,45 @@ function hl_matched(string $s, string|Stringable|SkinString|array|null $keywords
return $buf;
}
function format_time($ts, array $opts = array()) {
$default_opts = [
'date_only' => false,
'day_of_week' => false,
'seconds' => false,
'short_months' => false,
'skip_today_date' => false,
'skip_old_time' => false,
'at' => null,
];
$opts = array_merge($default_opts, $opts);
$is_today = date('Ymd') == date('Ymd', $ts);
$date = '';
$skip_old_time = false;
if (!$is_today || !$opts['skip_today_date']) {
$y = (int)date('Y', $ts);
$date .= date('j '.(!$opts['short_months'] ? 'F' : 'M'), $ts);
if ($y != (int)date('Y')) {
$date .= ' ' . $y;
if ($opts['skip_old_time']) {
$skip_old_time = true;
}
}
if ($opts['day_of_week']) {
$date .= ', ' . date('l', $ts);
}
}
if (!$opts['date_only'] && !$skip_old_time) {
if ($date != '') {
$date .= $opts['at'] ?? ' at ';
}
$date .= date('H:i', $ts);
if ($opts['seconds'])
$date .= date(':s', $ts);
}
return $date;
}

View File

@ -42,6 +42,112 @@ class AdminHandler extends request_handler {
redirect('/admin/login/', HTTPCode::Found);
}
function GET_errors() {
list($ip, $query, $url_query, $file_query, $line_query, $per_page)
= input('i:ip, query, url_query, file_query, i:line_query, i:per_page');
if (!$per_page)
$per_page = 10;
$db = DB();
$query = trim($query ?? '');
$url_query = trim($url_query ?? '');
$sql_where = [];
if ($ip) {
$sql_where[] = "ip='".$db->escape($ip)."'";
}
if ($query) {
$sql_where[] = "text LIKE '%".$db->escape($query)."%'";
}
if ($url_query) {
$sql_where[] = "url LIKE '%".$db->escape($url_query)."%'";
}
if ($file_query) {
$sql_where[] = "file LIKE '%".$db->escape($file_query)."%'";
}
if ($line_query) {
$sql_where[] = "line='".$db->escape($line_query)."'";
}
if (!empty($sql_where)) {
$sql_where = " WHERE ".implode(' AND ', $sql_where)." ";
} else {
$sql_where = '';
}
$count = (int)$db->result($db->query("SELECT COUNT(*) FROM backend_errors".$sql_where));
list($page, $pages, $offset) = get_page($per_page, $count);
$q = $db->query("SELECT *, INET_NTOA(ip) ip_s FROM backend_errors $sql_where ORDER BY id DESC LIMIT $offset, $per_page");
$list = [];
while ($row = $db->fetch($q)) {
$row['date'] = format_time($row['ts'], [
'seconds' => true,
'short_months' => true,
]);
$row['full_url'] = !str_starts_with($row['url'], 'https://') ? 'https://'.$row['url'] : $row['url'];
$error_name = getPHPErrorName((int)$row['errno']);
if (!is_null($error_name))
$row['errtype'] = $error_name;
$list[] = $row;
}
// url for pageNav
$url = '/admin/errors/';
$url_params = [];
if ($ip) {
$url_params['ip'] = $ip;
}
if ($query) {
$url_params['query'] = $query;
}
if ($url_query) {
$url_params['url_query'] = $url_query;
}
if ($file_query) {
$url_params['file_query'] = $file_query;
}
if ($line_query) {
$url_params['line_query'] = $line_query;
}
if (!empty($url_params)) {
$url .= '?'.http_build_query($url_params).'&';
} else {
$url .= '?';
}
$vars = [
'list' => $list,
'count' => $count,
'pn_page' => $page,
'pn_pages' => $pages,
'url' => $url,
];
if ($ip) {
$vars += [
'ip_filter' => ulong2ip($ip),
'ip' => $ip
];
}
$query_var_names = ['query', 'url_query', 'file_query', 'line_query'];
foreach ($query_var_names as $query_var_name) {
if ($$query_var_name) {
$vars += [$query_var_name => $$query_var_name];
}
}
set_skin_opts(['wide' => true]);
set_title('$admin_errors');
render('admin/errors',
...$vars);
}
function GET_uploads() {
list($error) = input('error');
$uploads = uploads::getAllUploads();

View File

@ -32,6 +32,9 @@ body {
user-select: none; /* Non-prefixed version, currently
supported by Chrome, Edge, Opera and Firefox */
}
.no-drag {
-webkit-user-drag: none;
}
.base-width {
max-width: $base-width;

View File

@ -0,0 +1,83 @@
.pn {
text-align: center;
}
.pn-button {
border: 1px $border-color solid;
background-color: $bg;
color: $fg;
display: block;
font-size: 14px;
text-align: center;
cursor: pointer;
padding: 7px 12px;
float: left;
transition: 0.075s border-color linear, 0.075s background-color linear, 0.075s color linear;
}
.pn-button:hover,
.pn-button:active {
border-color: $pn-button-border;
box-shadow: 0 1px 1px $pn-button-shadow;
}
.pn-button:active {
background-color: $pn-button-active;
}
.pn-button.is-page,
.pn-button-sep {
margin-right: -1px;
position: relative;
}
a.pn-button.is-page {
text-decoration: none;
}
.pn-button.is-page-cur {
color: $pn-button-current-text-color;
background-color: $pn-button-current;
border-color: $pn-button-border;
z-index: 6;
}
.pn-button.is-page:hover {
z-index: 5;
}
.pn-button.is-page:first-child {
border-radius: 3px 0 0 3px;
}
.pn-button.is-page:last-child {
border-radius: 0 3px 3px 0;
}
.pn-count {
float: right;
color: $grey;
font-size: 14px;
padding: 8px 0;
}
.pn.no-nav.no-results {
display: none;
}
.pn.no-nav > .pn-show-more {
display: none;
}
.pn.no-nav > .pn-buttons > div:not(.pn-count) {
display: none;
}
.pn.no-nav .pn-count {
float: none;
text-align: center;
}
.pn-buttons {
padding-top: $base-padding;
display: inline-block;
}
.pn-button-sep {
font-size: 14px;
float: left;
width: 5px;
border: 1px $pn-button-separator-border solid;
background: $pn-button-separator-background;
padding: 7px 1px;
}

View File

@ -1,3 +1,59 @@
.admin-page {
line-height: 155%;
}
table.admin-error-log {
margin-top: 10px;
width: 100%;
border-collapse: collapse;
border: 1px $grey solid;
font-size: 11px;
table-layout: fixed;
}
table.admin-error-log button {
font-size: 11px;
}
table.admin-error-log td,
table.admin-error-log th {
padding: 5px;
border: 1px $grey solid;
text-align: left;
vertical-align: top;
}
table.admin-error-log th {
overflow: hidden;
text-overflow: ellipsis;
}
table.admin-error-log td.admin-error-log-ms {
font-family: monospace;
overflow-x: auto;
}
table.admin-error-log td.admin-error-log-wrap {
word-break: break-all;
}
table.admin-error-log td.admin-error-log-nopadding {
padding: 0;
}
table.admin-error-log .admin-error-log-scrollable-wrap {
overflow-x: auto;
max-width: 100%;
height: 100%;
padding: 5px;
}
.admin-error-log-num {
padding: 1px 2px;
background: $light-grey;
color: $bg;
}
.admin-error-log-num > a {
color: $bg;
}
.admin-error-log-num > a:hover {
color: $link-color;
}
.admin-error-log-stacktrace-wrap {
padding-top: 5px;
}
.admin-error-log-link {
word-wrap: break-word;
}

View File

@ -7,6 +7,7 @@
@import "./app/files";
@import "./hljs/github.scss";
@import "./app/widgets";
@import "./app/pagenav";
@media screen and (max-width: 880px) {
@import "./app/mobile";

View File

@ -36,6 +36,15 @@ $error-block-fg: $fg;
$success-block-bg: #2a4b2d;
$success-block-fg: $fg;
$pn-button-border: $dark-grey;
$pn-button-shadow: rgba(0, 0, 0, 0.1);
$pn-button-active: $dark-grey;
$pn-button-separator-border: #e0e0e0;
$pn-button-separator-background: #f0f0f0;
$pn-button-current: #a7d2fd;
$pn-button-border: #92bae2;
$pn-button-current-text-color: $bg;
$head-items-separator: #5e6264;
// colors from https://github.com/Kelbster/highlightjs-material-dark-theme/blob/master/css/materialdark.css

View File

@ -36,6 +36,15 @@ $error-block-fg: #d13d3d;
$success-block-bg: #eff5f0;
$success-block-fg: #2a6f34;
$pn-button-border: #d0d0d0;
$pn-button-shadow: #f0f0f0;
$pn-button-active: #f4f4f4;
$pn-button-separator-border: #e0e0e0;
$pn-button-separator-background: #f0f0f0;
$pn-button-current: #a7d2fd;
$pn-button-border: #92bae2;
$pn-button-current-text-color: $fg;
$head-items-separator: #d0d0d0;
// github.com style (c) Vasily Polovnyov <vast@whiteants.net>

View File

@ -32,7 +32,7 @@ return (function() {
'articles/write/' => 'post_add',
'articles/([a-z0-9-]+)/{delete,edit}/' => 'post_${1} short_name=$(1)',
'admin/markdown-preview.ajax' => 'ajax_md_preview',
'admin/{uploads}/' => '${1}',
'admin/{uploads,errors}/' => '${1}',
'admin/uploads/{edit_note,delete}/(\d+)/' => 'upload_${1} id=$(1)'
]
];

View File

@ -52,6 +52,7 @@ function index($ctx, $admin_login) {
Authorized as <b>{$admin_login}</b> | <a href="/admin/logout/?token={$ctx->csrf('logout')}">Sign out</a><br>
<!--<a href="/admin/log/">Log</a><br/>-->
<a href="/admin/uploads/">Uploads</a><br>
<a href="/admin/errors/">{$ctx->lang('admin_errors')}</a><br>
</div>
HTML;
}
@ -402,6 +403,7 @@ HTML;
function markdownPreview($ctx, $unsafe_html, $title) {
return <<<HTML
<div class="blog-post">
<div class="blog-post">
{$ctx->if_true($title, '<div class="blog-post-title"><h1>'.$title.'</h1></div>')}
<div class="blog-post-text">{$unsafe_html}</div>
@ -419,3 +421,127 @@ return <<<HTML
HTML;
}
function errors($ctx,
array $list,
int $count,
int $pn_page,
int $pn_pages,
string $url,
?string $ip_filter = null,
?int $ip = null,
?string $query = null,
?string $url_query = null,
?string $file_query = null,
?string $line_query = null) {
return <<<HTML
{$ctx->bc([
['text' => $ctx->lang('admin_title'), 'url' => '/admin/'],
['text' => $ctx->lang('admin_errors')],
])}
<form action="/admin/errors/" method="get" class="admin_common_query_form">
{$ctx->if_true($ip, fn() => '<input type="hidden" name="ip" value="'.$ip.'" />')}
<input type="text" name="query" placeholder="text_like" value="{$query}" />
<input type="text" name="url_query" placeholder="url_like" value="{$url_query}" />
<input type="text" name="file_query" placeholder="file" value="{$file_query}" />
<input type="text" name="line_query" placeholder="line" value="{$line_query}" style="width: 50px" />
<input class="blue" type="submit" value="query" />
</form>
{$ctx->if_then_else(!empty($list),
fn() => $ctx->errors_table($list),
fn() => '<div class="empty_block">Error log is empty.</div>')}
{$ctx->pagenav($pn_page, $pn_pages, $url.'page={page}')}
HTML;
}
function errors_table($ctx,
array $list) {
return <<<HTML
<table border="1" width="100%" cellpadding="0" cellspacing="0" class="admin-error-log">
<thead>
<tr>
<th width="5%">Time</th>
<th width="20%">Source</th>
<th>Error</th>
</tr>
</thead>
<tbody>
{$ctx->for_each($list, fn($item) => $ctx->errors_table_item(
date: $item['date'],
is_cli: (bool)$item['is_cli'],
is_custom: (bool)$item['custom'],
user_agent: $item['ua'],
ip: (int)$item['ip'],
ip_s: $item['ip_s'],
full_url: $item['full_url'],
url: $item['url'],
file: $item['file'],
line: (int)$item['line'],
admin_id: (int)$item['admin_id'],
nl2br_text: $item['text'],
num: (int)$item['num'],
errtype: $item['errtype'] ?? null,
time: $item['time'],
stacktrace: $item['stacktrace'],
item_id: (int)$item['id']
))}
</tbody>
</table>
HTML;
}
function errors_table_item($ctx,
int $item_id,
string $date,
bool $is_cli,
bool $is_custom,
string $user_agent,
int $ip,
string $ip_s,
string $full_url,
string $url,
string $file,
int $line,
int $admin_id,
string $nl2br_text,
int $num,
?string $errtype,
string $time,
string $stacktrace) {
return <<<HTML
<tr>
<td>
{$date}
</td>
<td>
{$ctx->if_then_else(!$is_cli,
fn() => '<span class="admin-error-log-num"><a href="/admin/errors/?ip='.$ip.'">'.$ip_s.'</a></span> <a class="admin-error-log-link" href="'.$full_url.'">'.$url.'</a><br/>'.$user_agent,
fn() => '<span class="admin-error-log-num">cmd</span>')}
</td>
<td class="admin_error_log_ms">
{$ctx->if_true($admin_id,
fn() => '<span class="admin-error-log-num">admin='.$admin_id.'</span>')}
{$ctx->if_then_else($is_custom,
fn() => '<span class="admin-error-log-num">'.$num.', '.$time.'</span> <b>'.$file.'</b>:'.$line.'<br/>'
.'<span class="admin-error-log-num">'.$errtype.'</span> '.$nl2br_text,
fn() => '<span class="admin-error-log-num">'.$num.', '.$time.'</span> '.$nl2br_text)}
{$ctx->if_true($stacktrace,
fn() => $ctx->errors_table_item_stacktrace($item_id, $stacktrace))}
</td>
</tr>
HTML;
}
function errors_table_item_stacktrace($ctx, $item_id, $nl2br_stacktrace) {
return <<<HTML
<div class="admin-error-log-stacktrace-wrap">
<a href="javascript:void(0)" onclick="toggle(ge('admin_error_log_stacktrace{$item_id}'))">Show/hide stacktrace</a>
<div id="admin_error_log_stacktrace{$item_id}" style="display: none">{$nl2br_stacktrace}</div>
</div>
HTML;
}

View File

@ -85,7 +85,7 @@ $title = $pt->title;
return <<<HTML
{$ctx->if_true($ctx->year > $year, $ctx->articlesIndexYearLine, $year, $index === 0, $selected_lang->value)}
<tr class="blog-item-row{$ctx->if_not($post->visible, ' ishidden')}">
<td class="blog-item-date-cell">
<td class="blog-item-date-cell">1
<span class="blog-item-date">{$date}</span>
</td>
<td class="blog-item-title-cell">

View File

@ -96,6 +96,7 @@ admin_title: Admin
admin_password: Password
admin_login: Login
admin_books: Books
admin_errors: Errors
# /files
files: Files
@ -126,3 +127,12 @@ files_search_results_count:
- "%s results"
- "No results"
files_show_more: Show more
pages: 'Страницы'
pn_show_more: 'Показать ещё'
pn_prev: 'назад'
pn_next: 'вперёд'
pn_first: 'в начало'
pn_last: 'в конец'
#pn_goto: 'Перейти'
#pn_goto_n: '№ стр.'