From d210dd40f780121941a90c970fdf84d84d53a40c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andrej=20Rama=C5=A1euski?= <andrej@x2.cz> Date: Wed, 4 Jan 2023 22:21:57 +0100 Subject: [PATCH] Statistika a log --- Dockerfile | 2 + VERSION | 2 +- lib/PZ.pm | 4 +- lib/PZ/Controller/Log.pm | 62 +++++++++ lib/PZ/Controller/Shortcut.pm | 33 ++++- lib/PZ/Controller/Stat.pm | 34 +++++ lib/PZ/Schema/Result/Shortcut.pm | 2 +- openapi.yaml | 122 ++++++++++++++++++ templates/layouts/default.html.ep | 1 - templates/log.csv.html.ep | 2 + templates/shortcut.html.ep | 206 ++++++++++++++++++++++++++++++ templates/shortcuts-vue.html.ep | 53 ++++++++ templates/shortcuts.html.ep | 8 +- 13 files changed, 525 insertions(+), 6 deletions(-) create mode 100644 lib/PZ/Controller/Log.pm create mode 100644 lib/PZ/Controller/Stat.pm create mode 100644 templates/log.csv.html.ep create mode 100644 templates/shortcut.html.ep create mode 100644 templates/shortcuts-vue.html.ep diff --git a/Dockerfile b/Dockerfile index af287d8..943cc9d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,6 +9,8 @@ RUN apt-get update && apt-get install -y \ libcrypt-openssl-x509-perl \ libdata-guid-perl \ libdata-random-perl \ + libdatetime-perl \ + libdatetime-event-recurrence-perl \ libdata-validate-uri-perl \ libdbix-class-perl \ libdbd-pg-perl \ diff --git a/VERSION b/VERSION index 26aaba0..f0bb29e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.0 +1.3.0 diff --git a/lib/PZ.pm b/lib/PZ.pm index 96d5e13..cb5f488 100644 --- a/lib/PZ.pm +++ b/lib/PZ.pm @@ -103,7 +103,9 @@ sub startup { $r->get('/')->to(cb => sub { shift->render('index'); }); $r->post('/')->to('Shortcut#create'); - $r->get('/shortcuts')->to(cb => sub { shift->render('shortcuts'); }); + $r->get('/shortcuts')->to(cb => sub { shift->render('shortcuts-vue'); }); + $r->get('/shortcuts/:id')->to('Shortcut#get1'); + $r->get('/shortcuts/:id/log.csv')->to('Log#csv'); $r->get('/:shortcut')->to('Shortcut#redirect'); $r->get('/:shortcut/qr.png')->to('Shortcut#qr'); diff --git a/lib/PZ/Controller/Log.pm b/lib/PZ/Controller/Log.pm new file mode 100644 index 0000000..3a66a82 --- /dev/null +++ b/lib/PZ/Controller/Log.pm @@ -0,0 +1,62 @@ +package PZ::Controller::Log; +use Mojo::Base 'Mojolicious::Controller', -signatures; + +sub main ($c) { + $c->openapi->valid_input or return; + + my $shortcut = $c->stash->{user}->shortcuts({ + id => $c->stash->{id} + })->first; + + return $c->error(404, 'NOT_FOUND') if ! $shortcut; + + my ($log, @result); + + $log = $shortcut->log_items({}, + { + order_by => { -desc => 'time'}, + rows => $c->param('page_size'), + offset => $c->param('page_size') * ( $c->param('page_index') - 1 ), + } + ); + + ITEM: + while ( my $item = $log->next ) { + push @result, { + time => $item->time, + ip => $item->ip, + ua => $item->ua, + referrer => $item->referrer, + }; + } + + $c->render(json => \@result ); +} + +sub csv ($c) { + + my $shortcut = $c->current_user->shortcuts({ + id => $c->stash->{id} + })->first; + + return $c->error(404, 'NOT_FOUND') if ! $shortcut; + + my ($log, @result); + + $log = $shortcut->log_items({}, + { + order_by => { 'time'}, + } + ); + + $c->res->headers->content_type('text/csv'); + $c->res->headers->content_disposition( + 'attachment; filename=log.csv', + ); + $c->render( + template =>'log.csv', + log => $log, + ); +} +1; + diff --git a/lib/PZ/Controller/Shortcut.pm b/lib/PZ/Controller/Shortcut.pm index 3d60b5d..e7e4941 100644 --- a/lib/PZ/Controller/Shortcut.pm +++ b/lib/PZ/Controller/Shortcut.pm @@ -85,7 +85,10 @@ sub list ($c) { my @shortcuts = (); SHORTCUT: - foreach my $shortcut ( $c->stash->{user}->shortcuts({ deleted => undef }) ) { + foreach my $shortcut ( $c->stash->{user}->shortcuts( + { deleted => undef }, + { order_by => 'shortcut' }, + ) ) { push @shortcuts, $c->spec_filter( { $shortcut->get_columns }, 'Shortcut' ); @@ -94,6 +97,34 @@ sub list ($c) { $c->render(json => \@shortcuts, ); } +sub get ($c) { + + my $shortcut = $c->stash->{user}->shortcuts({ + id => $c->stash->{id} + })->first; + + if ( ! $shortcut ) { + $c->render( status => 404, text => 'not found' ); + return; + } + + $c->render(json => { $shortcut->get_columns } ); +} + +sub get1 ($c) { + + my $shortcut = $c->current_user->shortcuts({ + id => $c->stash->{id} + })->first; + + if ( ! $shortcut ) { + $c->render( status => 404, text => 'not found' ); + return; + } + + $c->stash->{shortcut} = $shortcut; + $c->stash->{template} = 'shortcut'; +} sub update ($c) { my $shortcut = $c->stash->{user}->shortcuts({ diff --git a/lib/PZ/Controller/Stat.pm b/lib/PZ/Controller/Stat.pm new file mode 100644 index 0000000..504a980 --- /dev/null +++ b/lib/PZ/Controller/Stat.pm @@ -0,0 +1,34 @@ +package PZ::Controller::Stat; +use Mojo::Base 'Mojolicious::Controller', -signatures; + +use DateTime; + +sub main ($c) { + my $shortcut = $c->stash->{user}->shortcuts({ + id => $c->stash->{id} + })->first; + + return $c->error(404, 'NOT_FOUND') if ! $shortcut; + + my %tz = (time_zone => 'Europe/Prague'); + my $begin = DateTime->from_epoch(%tz, epoch => $c->param('begin') / 1000); + my $end = DateTime->from_epoch(%tz, epoch => $c->param('end') / 1000); + + my ($stat, @result); + + $stat = $shortcut->stat_daily_items(period => { + between => [$begin, $end] + }); + + ITEM: + while ( my $item = $stat->next ) { + push @result, { + period => $item->period, + count => $item->count, + }; + } + + $c->render(json => \@result ); +} + +1; diff --git a/lib/PZ/Schema/Result/Shortcut.pm b/lib/PZ/Schema/Result/Shortcut.pm index 372b6f8..e5219d1 100644 --- a/lib/PZ/Schema/Result/Shortcut.pm +++ b/lib/PZ/Schema/Result/Shortcut.pm @@ -48,7 +48,7 @@ __PACKAGE__->has_many( ); __PACKAGE__->has_many( - stat_dayly_items => 'PZ::Schema::Result::StatDayly', + stat_daily_items => 'PZ::Schema::Result::StatDaily', { 'foreign.shortcut_id' => 'self.id', }, ); diff --git a/openapi.yaml b/openapi.yaml index 78115fe..c620092 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -38,6 +38,31 @@ components: type: integer description: KĂłd pĹ™esmÄ›rovánĂ enum: [301, 302] + StatisticItem: + type: object + properties: + period: + type: string + description: Obdobi + count: + type: integer + description: PoÄŤet + LogItem: + type: object + properties: + timestamp: + type: string + description: Datum a cas + ip: + type: string + description: IP adresa + ua: + type: string + description: User Agent + referrer: + type: string + description: HTTP Referrer + securitySchemes: Token: type: apiKey @@ -82,6 +107,21 @@ paths: $ref: '#/components/schemas/Shortcut' /shortcuts/{id}: + get: + tags: + - shortcuts + security: + - Token: [] + summary: "Zkratka" + operationId: ShortcutGet + x-mojo-to: shortcut#get + responses: + 204: + description: Shortcut + content: + application/json: + schema: + $ref: '#/components/schemas/Shortcut' put: tags: - shortcuts @@ -110,3 +150,85 @@ paths: responses: 204: description: Shortcut deleted + + /shortcuts/{id}/stat/: + get: + tags: + - shortcut + - statistic + security: + - Token: [] + summary: "Statistika pro zkratku" + operationId: ShortcutStatistic + x-mojo-to: Stat#main + parameters: + - name: id + in: path + required: true + example: 100345 + description: "Identifikator zkratky" + schema: + type: integer + - name: begin + in: query + required: true + description: "Zacatek" + schema: + type: integer + - name: end + in: query + required: true + description: "Konec" + schema: + type: integer + responses: + 200: + description: Statistika + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/StatisticItem' + + /shortcuts/{id}/log: + get: + tags: + - shortcuts + - statistic + security: + - Token: [] + summary: "Log pro zkratku" + operationId: ShortcutLog + x-mojo-to: Log#main + parameters: + - name: id + in: path + required: true + example: 100345 + description: "Identifikator zkratky" + schema: + type: integer + - name: page_size + in: query + example: 100 + description: "PoÄŤet zaznamu na strance" + schema: + type: integer + default: 1000 + - name: page_index + in: query + example: 1 + description: "Stranka" + schema: + type: integer + default: 1 + responses: + 200: + description: Log navstev pro zkratku + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/LogItem' diff --git a/templates/layouts/default.html.ep b/templates/layouts/default.html.ep index 2ce5033..6a96274 100644 --- a/templates/layouts/default.html.ep +++ b/templates/layouts/default.html.ep @@ -30,7 +30,6 @@ <script type="text/javascript" src="https://cdn-cdnjs-cloudflare.pirati.cz/ajax/libs/jsgrid/1.5.3/jsgrid.min.js"></script> <script src="https://cdn-unpkg.pirati.cz/vue@2.7.8/dist/vue.min.js"></script> <script src="<%= config->{styleguide} %>js/main.bundle.js"></script> - <link rel="stylesheet" href="/custom.css"/> </head> diff --git a/templates/log.csv.html.ep b/templates/log.csv.html.ep new file mode 100644 index 0000000..588624b --- /dev/null +++ b/templates/log.csv.html.ep @@ -0,0 +1,2 @@ +"datum a ÄŤas","ip adresa","browser","referrer"<%= "\x0D\x0A" =%><% while (my $entry = $log->next) { =%>"<%= $entry->time =%>","<%= $entry->ip =%>","<%= $entry->ua =%>","<%= $entry->referrer =%>"<%= "\x0D\x0A" =%><% } =%> + diff --git a/templates/shortcut.html.ep b/templates/shortcut.html.ep new file mode 100644 index 0000000..2081706 --- /dev/null +++ b/templates/shortcut.html.ep @@ -0,0 +1,206 @@ +% layout 'default'; + +<h1 class="head-alt-md md:head-alt-lg max-w-5xl mb-8">Zkratka "<%= $shortcut->shortcut %>"</h1> +% if (0) { +<h2 class="head-alt-sm my-4">Editace zkratky</h2> + +<form id="Edit" class="mb-8"> + +<div class="form-field form-field--error form-field--required mb-4"> + <label class="form-field__label" for="url">URL</label> + <div class="form-field__wrapper form-field__wrapper--shadowed"> + <input type="text" class="text-input form-field__control form-field--required" value="" v-model="shortcut.url" id="url" /> + </div> + <div class="form-field__error" v-if="shortcut.url === ''"></div> +</div> + +<div class="form-field form-field--error form-field--required mb-4"> + <label class="form-field__label" for="code">Typ pĹ™esmÄ›rovánĂ</label> + <div class="form-field__wrapper form-field__wrapper--shadowed select"> + <select v-model="shortcut.code" class="select__control form-field__control "> + <option value="301">301 trvalĂ©</option> + <option value="302">302 doÄŤasnĂ©</option> + </select> + </div> + <div class="form-field__error" v-if="shortcut.url === ''"></div> +</div> + +<button class="btn Xbtn--blue-300 btn--hoveractive" @click.prevent="updateShortcut"> + <div class="btn__body">UloĹľit zmÄ›ny</div> +</button> +<button class="btn Xbtn--red-600 btn--hoveractive" @click.prevent="delete_confirm_visible=true"> + <div class="btn__body">Smazat zkratku</div> +</button> + +<div class="modal__overlay toggle-modal-sample-1" id="modal-sample-1" v-if="delete_confirm_visible" v-cloak> + <div class="modal__content" role="dialog"> + <div class="modal__container w-full max-w-2xl" role="dialog"> + <div class="modal__container-body elevation-10"> + <button class="modal__close" title="ZavĹ™Ăt" @click.prevent="delete_confirm_visible = false"><i class="ico--cross"></i></button> + <div class="card "> + <div class="card__body "> + <h1 class="card-headline mb-2">Smazat zratku</h1> + <p class="card-body-text my-8"> + Opravdu chcete smazat zkratku? + </p> + + <p class="card-body-text"> + <button class="btn btn--green-300 btn--hoveractive text-sm" @click.prevent="deleteMeet()"> + <div class="btn__body"><i class="btn__inline-icon ico--bin"></i>ANO</div> + </button> + <button class="btn btn--orange-400 btn--hoveractive text-sm" @click.prevent="delete_confirm_visible = false"> + <div class="btn__body"><i class="btn__inline-icon ico--cross"></i>NE</div> + </button> + </p> + + </div> + </div> + + </div> + </div> + </div> +</div> +</form> +% } +<h2 class="head-alt-sm my-4">Statistika navštÄ›v</h2> +<div class="mb-8" style="width:100%; height: 600px;"><canvas id="stat"></canvas></div> + +<div class="mb-4 flex justify-between"> + + <h2 class="head-alt-sm">Log navštÄ›v</h2> + <div> + <button class="btn btn--orange-400 btn--hoveractive text-sm"> + <div class="btn__body"><i class="btn__inline-icon ico--download"></i><a href="/shortcuts/<%= $shortcut->id %>/log.csv">Stahnout CSV</a></div> + </button> + </div> +</div> +<div id="log"></div> + +<script src="https://cdn.jsdelivr.net/npm/chart.js"></script> +<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns/dist/chartjs-adapter-date-fns.bundle.min.js"></script> + +<script> + const end = Date.now(); + const begin = Date.now() - 31 * 24 * 3600 * 1000; + + const ctx = document.getElementById('stat'); + + var chart = new Chart(ctx, { + type: 'bar', + data: { + datasets: [{ + label: 'PoÄŤet klikĹŻ', + data: [], + borderWidth: 1 + }] + }, + options: { + scales: { + y: { + beginAtZero: true, + ticks: { + stepSize: 1 + }, + }, + x: { + min: begin, + max: end, + + type: 'time', + display: true, + offset: true, + time: { + unit: 'day', + tooltipFormat: 'dd.MM.yyyy', + displayFormats: { + day: 'dd.MM.yyyy' + } + } + }, + }, + parsing: { + xAxisKey: 'period', + yAxisKey: 'count' + } + } + }); + + fetch('/api/shortcuts/<%= $shortcut->id %>/stat/?begin=' + begin + '&end=' + end , { + headers: { 'X-Auth-Token': '<%= $c->current_user->token %>' }, + }) + .then((response) => response.json()) + .then((data) => { + chart.data.datasets[0].data = data; + chart.update(); + } + ); + + $("#log").jsGrid({ + width: "100%", + height: "auto", + + inserting: false, + editing: false, + sorting: true, + paging: true, + selecting: false, + autoload: true, + + controller: { + loadData: function(filter) { + + return $.ajax({ + url: "/api/shortcuts/<%= $shortcut->id %>/log/", + dataType: "json", + headers: { 'X-Auth-Token': '<%= $c->current_user->token %>' }, + data: filter, + }); + }, + }, + + fields: [ + { type: "text", name: "time", title: "Datum a ÄŤas", width: '11em' }, + { type: "text", name: "ip", title: "IP", width: '8em' }, + { type: "text", title: "Referrer", name: "referrer", width: '100%' }, + ] + }); + +// form +% if (0) { + + + const BASE_URL = "/api/shortcuts"; + const API_HEADERS = { + "Content-Type": "application/json", + "Accept": "application/json", + "X-Auth-Token": "<%= current_user->token %>", + }; + + var form = new Vue({ + el: '#Edit', + + data: { + shortcut: [], + delete_confirm_visible: false, + }, + + methods: { + fetchData: function() { + fetch(BASE_URL + '/<%= $shortcut->id %>', { + headers: API_HEADERS, + }) + .then((res) => res.json()) + .then(res => { + this.shortcut = res; + }) + }, + } + }); + + form.fetchData(); +% } +</script> + + + +</script> diff --git a/templates/shortcuts-vue.html.ep b/templates/shortcuts-vue.html.ep new file mode 100644 index 0000000..e94e200 --- /dev/null +++ b/templates/shortcuts-vue.html.ep @@ -0,0 +1,53 @@ +% layout 'default'; + +<h1 class="head-alt-md md:head-alt-lg max-w-5xl mb-8">Moje zkratky</h1> + +<table id="Shortcuts" class="table table-fixed table--striped" v-cloak> + <thead> + <tr> + <th>Zkratka</th> + <th>URL</th> + <th>PĹ™esmÄ›rovánĂ</th> + </tr> + </thead> + <tbody> + <tr v-for="shortcut in shortcuts" class="cursor-pointer" @click="window.location.href ='/shortcuts/' + shortcut.id"> + <td class="head-xs">{{shortcut.shortcut}}</td> + <td class="w-96">{{ shortcut.url }}</td> + <td class="">{{ shortcut.code == 301 ? '301 trvalĂ©' : '302 doÄŤasnĂ©'}}</td> + </tr> + </tbody> + +</table> + +<script type="module"> + const BASE_URL = "/api/shortcuts"; + const API_HEADERS = { + "Content-Type": "application/json", + "Accept": "application/json", + "X-Auth-Token": "<%= current_user->token %>", + }; + + var list = new Vue({ + el: '#Shortcuts', + + data: { + shortcuts: [], + }, + + methods: { + fetchData: function() { + fetch(BASE_URL, { + headers: API_HEADERS, + }) + .then((res) => res.json()) + .then(res => { + this.shortcuts = res; + }) + }, + } + }); + + list.fetchData(); + +</script> diff --git a/templates/shortcuts.html.ep b/templates/shortcuts.html.ep index 1c7fa00..0ddf381 100644 --- a/templates/shortcuts.html.ep +++ b/templates/shortcuts.html.ep @@ -86,7 +86,13 @@ $(function() { valueField: "code", textField: "name" }, - { type: "control", editButton: false, width: 60 } + { type: "text", title: "Statistika", name: "id", editing: false, + itemTemplate: function(value) { + return '<a href="/shortcuts/' + value + '">' + value + '</a>'; + }, + }, + { type: "control", editButton: false, width: 60 }, + ] }); }); -- GitLab