├── config
└── dcat-log-viewer.php
├── composer.json
├── LICENSE
├── src
├── DcatLogViewerServiceProvider.php
├── LogController.php
└── LogViewer.php
├── README.md
└── resources
└── view
└── log.blade.php
/config/dcat-log-viewer.php:
--------------------------------------------------------------------------------
1 | [
5 | 'prefix' => 'dcat-logs',
6 | 'namespace' => 'Dcat\LogViewer',
7 | 'middleware' => [],
8 | ],
9 |
10 | 'directory' => storage_path('logs'),
11 |
12 | 'search_page_items' => 500,
13 |
14 | 'page_items' => 30,
15 | ];
16 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "dcat/laravel-log-viewer",
3 | "description": "Laravel Log Viewer",
4 | "type": "library",
5 | "keywords": ["laravel", "log viewer"],
6 | "homepage": "https://github.com/jqhph/laravel-log-viewer",
7 | "license": "MIT",
8 | "authors": [
9 | {
10 | "name": "jqh",
11 | "email": "841324345@qq.com"
12 | }
13 | ],
14 | "require": {
15 | "php": ">=7.0"
16 | },
17 | "autoload": {
18 | "psr-4": {
19 | "Dcat\\LogViewer\\": "src/"
20 | }
21 | },
22 | "extra": {
23 | "laravel": {
24 | "providers": [
25 | "Dcat\\LogViewer\\DcatLogViewerServiceProvider"
26 | ]
27 | }
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Jiang Qinghua
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/src/DcatLogViewerServiceProvider.php:
--------------------------------------------------------------------------------
1 | loadViewsFrom(__DIR__.'/../resources/view', 'dcat-log-viewer');
12 |
13 | if ($this->app->runningInConsole()) {
14 | $this->publishes([__DIR__.'/../config' => config_path()], 'dcat-log-viewer');
15 | }
16 |
17 | $this->registerRoutes();
18 | }
19 |
20 | protected function registerRoutes()
21 | {
22 | app('router')->group([
23 | 'prefix' => config('dcat-log-viewer.route.prefix', 'dcat-logs'),
24 | 'namespace' => config('dcat-log-viewer.route.namespace', 'Dcat\LogViewer'),
25 | 'middleware' => config('dcat-log-viewer.route.middleware'),
26 | ], function ($router) {
27 | $router->get('/', ['as' => 'dcat-log-viewer', 'uses' => 'LogController@index',]);
28 | $router->get('download', ['as' => 'dcat-log-viewer.download', 'uses' => 'LogController@download',]);
29 | $router->get('{file}', ['as' => 'dcat-log-viewer.file', 'uses' => 'LogController@index',]);
30 | });
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | # Dcat Laravel Log Viewer
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 | `Dcat Log Viewer`是一个`Laravel`日志查看工具,支持大文件日志的查看和搜索功能,更改自[laravel-admin-extensions/log-viewer](https://github.com/laravel-admin-extensions/log-viewer)。
14 |
15 |
16 |
17 | 
18 |
19 | ## 功能
20 |
21 | - [x] 支持多层级目录
22 | - [x] 支持查看大文件日志
23 | - [x] 支持日志关键词检索
24 | - [x] 支持多层级目录文件名称搜索
25 | - [x] 支持下载功能
26 | - [x] 支持分页
27 | - [x] 支持手机页面
28 |
29 |
30 | ## 环境
31 |
32 | - PHP >= 7
33 | - laravel >= 5.5
34 |
35 |
36 | ## 安装
37 |
38 | ```bash
39 | composer require dcat/laravel-log-viewer
40 | ```
41 |
42 | 发布配置文件,此步骤可省略
43 |
44 | ```bash
45 | php artisan vendor:publish --tag=dcat-log-viewer
46 | ```
47 |
48 | 然后访问 `http://hostname/dcat-logs` 即可
49 |
50 | 配置文件
51 |
52 | ```php
53 |
54 | return [
55 | 'route' => [
56 | // 路由前缀
57 | 'prefix' => 'dcat-logs',
58 | // 命名空间
59 | 'namespace' => 'Dcat\LogViewer',
60 | // 中间件
61 | 'middleware' => [],
62 | ],
63 |
64 | // 日志目录
65 | 'directory' => storage_path('logs'),
66 |
67 | // 搜索页显示条目数(搜索后不分页,所以这个参数可以设置大一些)
68 | 'search_page_items' => 500,
69 |
70 | // 默认每页条目数
71 | 'page_items' => 30,
72 | ];
73 | ```
74 |
75 | ## License
76 | [The MIT License (MIT)](LICENSE).
77 |
--------------------------------------------------------------------------------
/src/LogController.php:
--------------------------------------------------------------------------------
1 | get('dir') ? trim($request->get('dir')) : '';
14 | $filename = $request->get('filename') ? trim($request->get('filename')) : '';
15 | $offset = $request->get('offset');
16 | $keyword = $request->get('keyword') ? trim($request->get('keyword')) : '';
17 | $lines = $keyword ? (config('dcat-log-viewer.search_page_items') ?: 500) : (config('dcat-log-viewer.page_items') ?: 30);
18 |
19 | $viewer = new LogViewer($this->getDirectory(), $dir, $file);
20 |
21 | $viewer->setKeyword($keyword);
22 | $viewer->setFilename($filename);
23 |
24 | return view('dcat-log-viewer::log', [
25 | 'dir' => $dir,
26 | 'logs' => $viewer->fetch($offset, $lines),
27 | 'logFiles' => $this->formatLogFiles($viewer, $dir),
28 | 'logDirs' => $viewer->getLogDirectories(),
29 | 'fileName' => $viewer->file,
30 | 'end' => $viewer->getFilesize(),
31 | 'prevUrl' => $viewer->getPrevPageUrl(),
32 | 'nextUrl' => $viewer->getNextPageUrl(),
33 | 'filePath' => $viewer->getFilePath(),
34 | 'size' => static::bytesToHuman($viewer->getFilesize()),
35 | ]);
36 | }
37 |
38 | public function download()
39 | {
40 | $request = app('request');
41 |
42 | $file = trim($request->get('file'));
43 | $dir = trim($request->get('dir'));
44 | $filename = trim($request->get('filename'));
45 | $keyword = trim($request->get('keyword'));
46 |
47 | $viewer = new LogViewer($this->getDirectory(), $dir, $file);
48 |
49 | $viewer->setKeyword($keyword);
50 | $viewer->setFilename($filename);
51 |
52 | return response()->download($viewer->getFilePath());
53 | }
54 |
55 | protected function getDirectory()
56 | {
57 | return config('dcat-log-viewer.directory') ?: storage_path('logs');
58 | }
59 |
60 | protected function formatLogFiles(LogViewer $logViewer, $currentDir)
61 | {
62 | return array_map(function ($value) use ($logViewer, $currentDir) {
63 | $file = $value;
64 | $dir = $currentDir;
65 |
66 | if (Str::contains($value, '/')) {
67 | $array = explode('/', $value);
68 | $file = end($array);
69 |
70 | array_pop($array);
71 | $dir = implode('/', $array);
72 | }
73 |
74 | return [
75 | 'file' => $value,
76 | 'url' => route('dcat-log-viewer.file', ['file' => $file, 'dir' => $dir]),
77 | 'active' => $logViewer->isCurrentFile($value),
78 | ];
79 | }, $logViewer->getLogFiles());
80 | }
81 |
82 | protected static function bytesToHuman($bytes)
83 | {
84 | $units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
85 |
86 | for ($i = 0; $bytes > 1024; $i++) {
87 | $bytes /= 1024;
88 | }
89 |
90 | return round($bytes, 2).' '.$units[$i];
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/src/LogViewer.php:
--------------------------------------------------------------------------------
1 | 'black',
56 | 'ALERT' => 'navy',
57 | 'CRITICAL' => 'maroon',
58 | 'ERROR' => 'danger',
59 | 'WARNING' => 'orange',
60 | 'NOTICE' => 'light-blue',
61 | 'INFO' => 'primary',
62 | 'DEBUG' => 'light',
63 | '' => '',
64 | ];
65 |
66 | protected $keyword;
67 |
68 | protected $filename;
69 |
70 | /**
71 | * LogViewer constructor.
72 | *
73 | * @param null $file
74 | */
75 | public function __construct($basePath, $dir, $file = null)
76 | {
77 | $this->basePath = $this->getRealPath(rtrim($basePath, '/'));
78 | $this->currentDirectory = $this->formatPath(rtrim($dir, '/'));
79 | $this->file = $this->formatPath($file);
80 | $this->files = new Filesystem();
81 | }
82 |
83 | protected function getRealPath($path)
84 | {
85 | try {
86 | $paths = explode('/', $path);
87 |
88 | $result = '';
89 | foreach ($paths as $v) {
90 | $result .= $v.'/';
91 |
92 | $current = rtrim($result, '/');
93 | if (is_link($current)) {
94 | $result = readlink($current).'/';
95 | }
96 | }
97 |
98 | return rtrim($result, '/');
99 | } catch (\Throwable $e) {
100 | return $path;
101 | }
102 | }
103 |
104 | protected function formatPath($path)
105 | {
106 | return $path ? str_replace(['../'], '', $path) : '';
107 | }
108 |
109 | /**
110 | * Get file path by giving log file name.
111 | *
112 | * @return string
113 | *
114 | */
115 | public function getFilePath()
116 | {
117 | if (!$this->filePath) {
118 | $path = $this->mergeDirectory().'/'.$this->getFile();
119 |
120 | $this->filePath = is_file($path) ? $path : false;
121 | }
122 |
123 | return $this->filePath;
124 | }
125 |
126 | public function setKeyword($value)
127 | {
128 | $this->keyword = strtolower($value);
129 | }
130 |
131 | public function setFilename($value)
132 | {
133 | $this->filename = $this->formatPath($value);
134 | }
135 |
136 | /**
137 | * Get size of log file.
138 | *
139 | * @return int
140 | */
141 | public function getFilesize()
142 | {
143 | if (!$this->getFilePath()) {
144 | return 0;
145 | }
146 |
147 | return filesize($this->getFilePath());
148 | }
149 |
150 | /**
151 | * Get log file list in storage.
152 | *
153 | * @return array
154 | */
155 | public function getLogFiles()
156 | {
157 | if ($this->filename) {
158 | return collect($this->files->allFiles($this->mergeDirectory()))->map(function (\SplFileInfo $fileInfo) {
159 | return $this->replaceBasePath($fileInfo->getRealPath());
160 | })->filter(function ($v) {
161 | return Str::contains($v, $this->filename);
162 | })->toArray();
163 | }
164 |
165 | $files = glob($this->mergeDirectory().'/*.*');
166 | //$files = array_combine($files, array_map('filemtime', $files));
167 | rsort($files);
168 |
169 | return array_map('basename', $files);
170 | }
171 |
172 | public function getLogDirectories()
173 | {
174 | return array_map([$this, 'replaceBasePath'], $this->files->directories($this->mergeDirectory()));
175 | }
176 |
177 | protected function replaceBasePath($v)
178 | {
179 | $basePath = str_replace('\\', '/', $this->getLogBasePath());
180 |
181 | return str_replace($basePath.'/', '', str_replace('\\', '/', $v));
182 | }
183 |
184 | public function mergeDirectory()
185 | {
186 | if (!$this->currentDirectory) {
187 | return $this->getLogBasePath();
188 | }
189 |
190 | return $this->getLogBasePath().'/'.$this->currentDirectory;
191 | }
192 |
193 | /**
194 | * @return string
195 | */
196 | public function getLogBasePath()
197 | {
198 | return $this->basePath;
199 | }
200 |
201 | /**
202 | * Get the last modified log file.
203 | *
204 | * @return string
205 | */
206 | public function getLastModifiedLog()
207 | {
208 | return current($this->getLogFiles());
209 | }
210 |
211 | public function getFile()
212 | {
213 | if (! $this->file) {
214 | $this->file = $this->getLastModifiedLog();
215 | }
216 |
217 | return $this->file;
218 | }
219 |
220 | public function isCurrentFile($file)
221 | {
222 | return $this->replaceBasePath($this->getFilePath()) === trim($this->currentDirectory.'/'.$file, '/');
223 | }
224 |
225 | /**
226 | * Get previous page url.
227 | *
228 | * @return bool|string
229 | */
230 | public function getPrevPageUrl()
231 | {
232 | if (
233 | !$this->getFilePath()
234 | || $this->pageOffset['end'] >= $this->getFilesize() - 1
235 | || $this->keyword
236 | ) {
237 | return false;
238 | }
239 |
240 | return route('dcat-log-viewer.file', [
241 | 'file' => $this->getFile(),
242 | 'offset' => $this->pageOffset['end'],
243 | 'keyword' => $this->keyword,
244 | 'dir' => $this->currentDirectory,
245 | 'filename' => $this->filename,
246 | ]);
247 | }
248 |
249 | /**
250 | * Get Next page url.
251 | *
252 | * @return bool|string
253 | */
254 | public function getNextPageUrl()
255 | {
256 | if (
257 | !$this->getFilePath()
258 | || $this->pageOffset['start'] == 0
259 | || $this->keyword
260 | ) {
261 | return false;
262 | }
263 |
264 | return route('dcat-log-viewer.file', [
265 | 'file' => $this->getFile(),
266 | 'offset' => -$this->pageOffset['start'],
267 | 'keyword' => $this->keyword,
268 | 'dir' => $this->currentDirectory,
269 | 'filename' => $this->filename,
270 | ]);
271 | }
272 |
273 | /**
274 | * Fetch logs by giving offset.
275 | *
276 | * @param int $seek
277 | * @param int $lines
278 | * @param int $buffer
279 | *
280 | * @return array
281 | *
282 | * @see http://www.geekality.net/2011/05/28/php-tail-tackling-large-files/
283 | */
284 | public function fetch($seek = 0, $lines = 20, $buffer = 4096)
285 | {
286 | $logs = $this->read($seek, $lines, $buffer);
287 |
288 | if (!$this->keyword || !$logs) {
289 | return $logs;
290 | }
291 |
292 | $result = [];
293 |
294 | foreach ($logs as $log) {
295 | if (Str::contains(strtolower(implode(' ', $log)), $this->keyword)) {
296 | $result[] = $log;
297 | }
298 | }
299 |
300 | if (count($result) >= $lines || !$this->getNextOffset()) {
301 | return $result;
302 | }
303 |
304 | return array_merge($result, $this->fetch($this->getNextOffset(), $lines - count($result), $buffer));
305 | }
306 |
307 | public function getNextOffset()
308 | {
309 | if ($this->pageOffset['start'] == 0) {
310 | return false;
311 | }
312 |
313 | return -$this->pageOffset['start'];
314 | }
315 |
316 | protected function read($seek = 0, $lines = 20, $buffer = 4096)
317 | {
318 | if (! $this->getFilePath()) {
319 | return [];
320 | }
321 |
322 | $f = fopen($this->getFilePath(), 'rb');
323 |
324 | $type = (preg_match('/\[(\d{4}(?:-\d{2}){2} \d{2}(?::\d{2}){2})\] (\w+)*/', fread($f, 34)) == 0) ? 'txt' : '';
325 |
326 | if ($seek) {
327 | fseek($f, abs($seek));
328 | } else {
329 | fseek($f, 0, SEEK_END);
330 | }
331 |
332 | if (fread($f, 1) != "\n") {
333 | $lines -= 1;
334 | }
335 | fseek($f, -1, SEEK_CUR);
336 |
337 | // 从前往后读,上一页
338 | // Start reading
339 | if ($seek > 0) {
340 | $output = $this->readPrevPage($f, $lines, $buffer, $type);
341 | // 从后往前读,下一页
342 | } else {
343 | $output = $this->readNextPage($f, $lines, $buffer, $type);
344 | }
345 |
346 | fclose($f);
347 |
348 | return $this->parseLog($output, $type);
349 | }
350 |
351 | protected function readPrevPage($f, &$lines, $buffer, $type = '')
352 | {
353 | $rule = ($type == 'txt') ? "\n" : "\n[20";
354 | $output = '';
355 |
356 | $this->pageOffset['start'] = ftell($f);
357 |
358 | while (!feof($f) && $lines >= 0) {
359 | $output = $output . ($chunk = fread($f, $buffer));
360 | $lines -= substr_count($chunk, $rule);
361 | }
362 |
363 | $this->pageOffset['end'] = ftell($f);
364 |
365 | while ($lines++ < 0) {
366 | $strpos = strrpos($output, $rule) + 1;
367 | $_ = mb_strlen($output, '8bit') - $strpos;
368 | $output = substr($output, 0, $strpos);
369 | $this->pageOffset['end'] -= $_;
370 | }
371 |
372 | return $output;
373 | }
374 |
375 | // @lila
376 | protected function readNextPage($f, &$lines, $buffer, $type = '')
377 | {
378 | $rule = ($type == 'txt') ? "\n" : "\n[20";
379 | $output = '';
380 |
381 | $this->pageOffset['end'] = ftell($f);
382 |
383 | while (ftell($f) > 0 && $lines >= 0) {
384 | $offset = min(ftell($f), $buffer);
385 | fseek($f, -$offset, SEEK_CUR);
386 | $output = ($chunk = fread($f, $offset)) . $output;
387 | fseek($f, -mb_strlen($chunk, '8bit'), SEEK_CUR);
388 | $lines -= substr_count($chunk, $rule);
389 | }
390 |
391 | $this->pageOffset['start'] = ftell($f);
392 |
393 | while ($lines++ < 0) {
394 | $strpos = strpos($output, $rule) + 1;
395 | $output = substr($output, $strpos);
396 | $this->pageOffset['start'] += $strpos;
397 | }
398 |
399 | return $output;
400 | }
401 |
402 | /**
403 | * Get tail logs in log file.
404 | *
405 | * @param int $seek
406 | *
407 | * @return array
408 | */
409 | public function tail($seek)
410 | {
411 | // Open the file
412 | $f = fopen($this->getFilePath(), 'rb');
413 |
414 | if (!$seek) {
415 | // Jump to last character
416 | fseek($f, -1, SEEK_END);
417 | } else {
418 | fseek($f, abs($seek));
419 | }
420 |
421 | $output = '';
422 |
423 | while (!feof($f)) {
424 | $output .= fread($f, 4096);
425 | }
426 |
427 | $pos = ftell($f);
428 |
429 | fclose($f);
430 |
431 | $logs = [];
432 |
433 | foreach ($this->parseLog(trim($output)) as $log) {
434 | $logs[] = $this->renderTableRow($log);
435 | }
436 |
437 | return [$pos, $logs];
438 | }
439 |
440 | /**
441 | * Render table row.
442 | *
443 | * @param $log
444 | *
445 | * @return string
446 | */
447 | protected function renderTableRow($log)
448 | {
449 | $color = self::$levelColors[$log['level']] ?? 'black';
450 |
451 | $index = uniqid();
452 |
453 | $button = '';
454 |
455 | if (!empty($log['trace'])) {
456 | $button = " Exception";
457 | }
458 |
459 | $trace = '';
460 |
461 | if (!empty($log['trace'])) {
462 | $trace = "
463 | {$log['trace']} |
464 |
";
465 | }
466 |
467 | return <<
469 | {$log['level']} |
470 | {$log['env']} |
471 | {$log['time']} |
472 | {$log['info']} |
473 | $button |
474 |
475 | $trace
476 | TPL;
477 | }
478 |
479 | /**
480 | * Parse raw log text to array.
481 | *
482 | * @param $raw
483 | *
484 | * @return array
485 | */
486 | protected function parseLog($raw, $type = '')
487 | {
488 | if ($type == 'txt') {
489 | $logs = preg_split('/(\r\n|\n)/', trim($raw), -1, PREG_SPLIT_NO_EMPTY);
490 | } else {
491 | $logs = preg_split('/\[(\d{4}(?:-\d{2}){2} \d{2}(?::\d{2}){2})\] (\w+)\.(\w+):((?:(?!{"exception").)*)?/', trim($raw), -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY);
492 |
493 | foreach ($logs as $index => $log) {
494 | if (preg_match('/^\d{4}/', $log)) {
495 | break;
496 | } else {
497 | unset($logs[$index]);
498 | }
499 | }
500 | }
501 |
502 | if (empty($logs)) {
503 | return [];
504 | }
505 |
506 | $parsed = [];
507 | $logs = array_values($logs);
508 |
509 | if ($type == 'txt') {
510 | foreach ($logs as $log) {
511 | $parsed[] = [
512 | 'time' => '',
513 | 'env' => '',
514 | 'level' => '',
515 | 'info' => $log,
516 | 'trace' => '',
517 | ];
518 | }
519 | $parsed = array_reverse($parsed);
520 | } else {
521 | foreach (array_chunk($logs, 5) as $log) {
522 | $parsed[] = [
523 | 'time' => $log[0] ?? '',
524 | 'env' => $log[1] ?? '',
525 | 'level' => $log[2] ?? '',
526 | 'info' => $log[3] ?? '',
527 | 'trace' => $this->replaceRootPath(trim($log[4] ?? '')),
528 | ];
529 | }
530 | rsort($parsed);
531 | }
532 |
533 | unset($logs);
534 |
535 | return $parsed;
536 | }
537 |
538 | protected function replaceRootPath($content)
539 | {
540 | $basePath = str_replace('\\', '/', base_path() . '/');
541 |
542 | return str_replace($basePath, '', str_replace(['\\\\', '\\'], '/', $content));
543 | }
544 | }
545 |
--------------------------------------------------------------------------------
/resources/view/log.blade.php:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Laravel log viewer
7 |
8 |
9 |
10 |
11 |
12 |
16 |
222 |
223 |
224 |
225 |
226 |
227 |
228 |
229 |
230 |
231 | Dcat Log Viewer
232 |
233 |
234 |
235 |
248 |
249 |
254 |
255 |
276 |
277 |
278 |
279 |
280 |
281 |
282 |
283 |
284 |
285 |
286 |
314 |
315 |
316 |
317 |
318 |
319 |
320 |
321 |
322 | |
323 | Level |
324 | Env |
325 | Time |
326 | Message |
327 | |
328 |
329 |
330 |
331 |
332 |
333 | @foreach($logs as $index => $log)
334 |
335 | | {{ $index + 1 }} |
336 | {{ $log['level'] }} |
337 | {{ $log['env'] }} |
338 | {{ $log['time'] }} |
339 | {{ $log['info'] }} |
340 |
341 | @if(!empty($log['trace']))
342 |
343 | @endif
344 | |
345 |
346 |
347 | @if (!empty($log['trace']))
348 |
349 | {{ $log['trace'] }} |
350 |
351 | @endif
352 |
353 | @endforeach
354 |
355 |
356 |
357 |
358 |
359 |
360 |
361 |
362 |
380 |
381 |
382 |
383 |
384 |
385 |
386 |
387 |
388 |
389 |
390 | {{----}}
391 |
392 |
393 |
394 |
395 |
--------------------------------------------------------------------------------