├── LICENSE ├── favicon.pxm ├── public ├── favicon.ico ├── img │ ├── glyphicons-halflings.png │ └── glyphicons-halflings-white.png ├── fonts │ ├── glyphicons-halflings-regular.eot │ ├── glyphicons-halflings-regular.ttf │ └── glyphicons-halflings-regular.woff ├── css │ ├── site.css │ └── bootstrap-theme.min.css └── js │ ├── jquery.cookie.js │ ├── site.js │ └── bootstrap.min.js ├── Changes ├── t ├── HRForecast │ ├── Web │ │ ├── 01_use.t │ │ ├── 03_merge_params.t │ │ └── 02_calc_term.t │ └── Calculator │ │ ├── 01_use.t │ │ ├── 04_find_minus_days_of_from.t │ │ ├── 05_filter_by_from.t │ │ ├── 02_calculate.t │ │ └── 03_calculation.t └── 00_compile.t ├── views ├── index.tx ├── pifr_dummy.tx ├── table.tx ├── list.tx ├── pifr.tx ├── edit.tx ├── view_complex.tx ├── view.tx ├── docs.tx ├── base.tx ├── ifr_complex.tx ├── ifr.tx ├── add_complex.tx └── edit_complex.tx ├── .gitignore ├── config.pl ├── app.psgi ├── cpanfile ├── README.md ├── lib ├── HRForecast.pm └── HRForecast │ ├── Calculator.pm │ ├── Data.pm │ └── Web.pm ├── schema.sql ├── Build.PL ├── hrforecast.pl └── META.json /LICENSE: -------------------------------------------------------------------------------- 1 | Minilla cannot detect license terms. -------------------------------------------------------------------------------- /favicon.pxm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kazeburo/HRForecast/HEAD/favicon.pxm -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kazeburo/HRForecast/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /Changes: -------------------------------------------------------------------------------- 1 | Revision history for Perl extension HRForecast 2 | 3 | {{$NEXT}} 4 | 5 | - original version 6 | 7 | -------------------------------------------------------------------------------- /public/img/glyphicons-halflings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kazeburo/HRForecast/HEAD/public/img/glyphicons-halflings.png -------------------------------------------------------------------------------- /public/img/glyphicons-halflings-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kazeburo/HRForecast/HEAD/public/img/glyphicons-halflings-white.png -------------------------------------------------------------------------------- /public/fonts/glyphicons-halflings-regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kazeburo/HRForecast/HEAD/public/fonts/glyphicons-halflings-regular.eot -------------------------------------------------------------------------------- /public/fonts/glyphicons-halflings-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kazeburo/HRForecast/HEAD/public/fonts/glyphicons-halflings-regular.ttf -------------------------------------------------------------------------------- /public/fonts/glyphicons-halflings-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kazeburo/HRForecast/HEAD/public/fonts/glyphicons-halflings-regular.woff -------------------------------------------------------------------------------- /t/HRForecast/Web/01_use.t: -------------------------------------------------------------------------------- 1 | use strict; 2 | use warnings; 3 | use Test::More tests => 1; 4 | 5 | use_ok 'HRForecast::Web'; 6 | 7 | done_testing; 8 | 9 | 10 | -------------------------------------------------------------------------------- /t/HRForecast/Calculator/01_use.t: -------------------------------------------------------------------------------- 1 | use strict; 2 | use warnings; 3 | use Test::More tests => 1; 4 | 5 | use_ok 'HRForecast::Calculator'; 6 | 7 | done_testing; 8 | 9 | 10 | -------------------------------------------------------------------------------- /t/00_compile.t: -------------------------------------------------------------------------------- 1 | use strict; 2 | use warnings; 3 | use Test::More; 4 | 5 | use_ok $_ for qw( 6 | HRForecast 7 | HRForecast::Web 8 | ); 9 | 10 | done_testing; 11 | 12 | 13 | -------------------------------------------------------------------------------- /views/index.tx: -------------------------------------------------------------------------------- 1 | : cascade base 2 | 3 | : around content -> { 4 |

Say Hello to HRForecast

5 | 6 |

HRForecastはメトリクスを登録し、グラフとして表示またはCSVとしてダウンロードできるツールです

7 |

使い方はドキュメントページを参照してください

8 | : } 9 | 10 | 11 | -------------------------------------------------------------------------------- /public/css/site.css: -------------------------------------------------------------------------------- 1 | .highlight { 2 | text-decoration: underline; 3 | } 4 | 5 | #tooltip { 6 | display: none; 7 | position: absolute; 8 | padding: 5px; 9 | background: white; 10 | border: solid thin black; 11 | z-index: 1000; 12 | opacity: 0.8; 13 | } 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | cover_db 2 | META.yml 3 | Makefile 4 | blib 5 | inc 6 | pm_to_blib 7 | MANIFEST 8 | Makefile.old 9 | nytprof.out 10 | MANIFEST.bak 11 | *.sw[po] 12 | *~ 13 | .DS_Store 14 | MYMETA.* 15 | /extlib/ 16 | /HRForecast-* 17 | /.build 18 | /_build_params 19 | /Build 20 | !Build/ 21 | !META.json 22 | !LICENSE 23 | -------------------------------------------------------------------------------- /config.pl: -------------------------------------------------------------------------------- 1 | { 2 | dsn => 'dbi:mysql:hrforecast;hostname=127.0.0.1', 3 | username => '', 4 | password => '', 5 | port => '5127', 6 | host => '127.0.0.1', 7 | front_proxy => [], 8 | allow_from => [], 9 | header => [], 10 | #header => [append => ['Access-Control-Allow-Origin' => '*']], 11 | }; 12 | 13 | -------------------------------------------------------------------------------- /app.psgi: -------------------------------------------------------------------------------- 1 | use FindBin; 2 | use lib "$FindBin::Bin/extlib/lib/perl5"; 3 | use lib "$FindBin::Bin/lib"; 4 | use File::Basename; 5 | use Plack::Builder; 6 | use HRForecast::Web; 7 | 8 | my $root_dir = File::Basename::dirname(__FILE__); 9 | 10 | my $app = HRForecast::Web->psgi($root_dir); 11 | builder { 12 | enable 'ReverseProxy'; 13 | enable 'Static', 14 | path => qr!^/(?:(?:css|js|images)/|favicon\.ico$)!, 15 | root => $root_dir . '/public'; 16 | $app; 17 | }; 18 | 19 | -------------------------------------------------------------------------------- /cpanfile: -------------------------------------------------------------------------------- 1 | 2 | requires 'Kossy' => '0.27'; 3 | requires 'DBIx::Sunny' => '0.14'; 4 | requires 'DBD::mysql' => '4.018'; 5 | requires 'Time::Piece' => '1.15'; 6 | requires 'Time::Piece::MySQL' => '0.06'; 7 | requires 'HTTP::Date' => 0; 8 | requires 'Log::Minimal' => '0.09'; 9 | requires 'List::MoreUtils' => '0'; 10 | requires 'Starlet' => '0.20'; 11 | requires 'Parallel::Scoreboard' => '0.03'; 12 | requires 'Plack::Builder::Conditionals' => '0.03'; 13 | requires 'Scope::Container::DBI' => '0.03'; 14 | requires 'Plack::Middleware::Scope::Container' => '0.01'; 15 | requires 'Plack::Middleware::Header' => '0.04'; 16 | requires 'Test::MockObject' => 0; 17 | -------------------------------------------------------------------------------- /t/HRForecast/Calculator/04_find_minus_days_of_from.t: -------------------------------------------------------------------------------- 1 | use strict; 2 | use warnings; 3 | use Test::More; 4 | use HRForecast; 5 | use HRForecast::Calculator; 6 | 7 | my $calculator = HRForecast::Calculator->new(); 8 | 9 | subtest 'find_minus_days_of_from' => sub { 10 | is($calculator->find_minus_days_of_from(''), 0); 11 | is($calculator->find_minus_days_of_from('runningtotal'), 0); 12 | is($calculator->find_minus_days_of_from('runningtotal_by_month'), 31); 13 | is($calculator->find_minus_days_of_from('difference_plus'), 1); 14 | is($calculator->find_minus_days_of_from('difference_plus'), 1); 15 | }; 16 | 17 | done_testing; 18 | 19 | 20 | -------------------------------------------------------------------------------- /views/pifr_dummy.tx: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 13 | 14 | 15 | 16 |
17 |
18 |
19 |
20 |
21 |
22 |

データを選択してください

23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NAME 2 | 3 | HRForecast - A Graphing/Visualization Tool 4 | 5 | # DESCRIPTION 6 | 7 | HRForecast is Graphing/Data Visualization tool. Whereas [GrowthForecast](http://search.cpan.org/perldoc?GrowthForecast) (HRForecast's older brother) is a tool for monitoring real-time data, HRForecast is aimed to keep track of data for a relatively long time frame. 8 | 9 | So while [GrowthForecast](http://search.cpan.org/perldoc?GrowthForecast) shows you data in terms of average rates (i.e. RRD style) and the smallest graphing unit for a given data set is "1 minute average", HRForecast uses raw numbers and the smallest unit is "value at a given date". 10 | 11 | # AUTHOR 12 | 13 | Masahiro Nagano `` 14 | 15 | 16 | -------------------------------------------------------------------------------- /t/HRForecast/Web/03_merge_params.t: -------------------------------------------------------------------------------- 1 | use strict; 2 | use warnings; 3 | use Test::More; 4 | use HRForecast; 5 | use HRForecast::Web; 6 | 7 | my $obj = bless({}, 'HRForecast::Web'); 8 | 9 | local $HRForecast::CONFIG = {}; 10 | 11 | subtest 'merge_params' => sub { 12 | my $merge_params = HRForecast::Web::create_merge_params(['a', 1, 'b', 2]); 13 | is_deeply($merge_params->({c => 3}), ['c', 3, 'a', 1, 'b', 2]); 14 | }; 15 | 16 | subtest 'merge_params overwrite' => sub { 17 | my $merge_params = HRForecast::Web::create_merge_params(['a', 1, 'b', 2]); 18 | is_deeply($merge_params->({a => 3}), ['a', 3, 'b', 2]); 19 | }; 20 | 21 | subtest 'merge_params value=empty' => sub { 22 | my $merge_params = HRForecast::Web::create_merge_params(['a', '', 'b', 2]); 23 | is_deeply($merge_params->({c => ''}), ['b', 2]); 24 | }; 25 | 26 | done_testing; 27 | 28 | 29 | -------------------------------------------------------------------------------- /views/table.tx: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 14 | 15 | HRForecast 16 | 17 | 18 |
19 | 20 | : for $table -> $row { 21 | 22 | : for $row -> $col { 23 | 24 | : } 25 | 26 | : } 27 |
<: $col :>
28 |
29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /lib/HRForecast.pm: -------------------------------------------------------------------------------- 1 | package HRForecast; 2 | 3 | use strict; 4 | use warnings; 5 | use utf8; 6 | 7 | our $VERSION = 0.01; 8 | 9 | our $CONFIG; 10 | sub config { $CONFIG }; 11 | 12 | 13 | 1; 14 | 15 | __END__ 16 | 17 | =head1 NAME 18 | 19 | HRForecast - A Graphing/Visualization Tool 20 | 21 | =head1 DESCRIPTION 22 | 23 | HRForecast is Graphing/Data Visualization tool. Whereas L (HRForecast's older brother) is a tool for monitoring real-time data, HRForecast is aimed to keep track of data for a relatively long time frame. 24 | 25 | So while L shows you data in terms of average rates (i.e. RRD style) and the smallest graphing unit for a given data set is "1 minute average", HRForecast uses raw numbers and the smallest unit is "value at a given date". 26 | 27 | =head1 AUTHOR 28 | 29 | Masahiro Nagano C<< >> 30 | 31 | 32 | =cut 33 | -------------------------------------------------------------------------------- /t/HRForecast/Web/02_calc_term.t: -------------------------------------------------------------------------------- 1 | use strict; 2 | use warnings; 3 | use Test::More; 4 | use HRForecast; 5 | use HRForecast::Web; 6 | 7 | my $obj = bless({}, 'HRForecast::Web'); 8 | 9 | local $HRForecast::CONFIG = {}; 10 | 11 | subtest 't => w' => sub { 12 | my ($from, $to) = $obj->calc_term(t => 'w'); 13 | is($to->epoch, int(time / 3600) * 3600); 14 | is(($to - $from), (10 * 24 * 60 * 60)); 15 | }; 16 | 17 | subtest 't => m' => sub { 18 | my ($from, $to) = $obj->calc_term(t => 'm'); 19 | is($to->epoch, int(time / 3600) * 3600); 20 | is(($to - $from), (40 * 24 * 60 * 60)); 21 | }; 22 | 23 | subtest 't => y' => sub { 24 | my ($from, $to) = $obj->calc_term(t => 'y'); 25 | is($to->epoch, int(time / 3600) * 3600); 26 | is(($to - $from), (400 * 24 * 60 * 60)); 27 | }; 28 | 29 | subtest 't => range' => sub { 30 | my ($from, $to) = $obj->calc_term(t => 'range', offset => 5000, period => 36000); 31 | is($to->epoch, int((time - 5000) / 3600) * 3600); 32 | is(($to - $from), 36000); 33 | }; 34 | 35 | subtest 't => c, and from&to' => sub { 36 | my ($from, $to) = $obj->calc_term(t => 'c', from => '2014-06-08 12:34:56', to => '2014-07-09 01:23:45'); 37 | is($from->epoch, HTTP::Date::str2time('2014-06-08 12:00:00')); 38 | is($to->epoch, HTTP::Date::str2time('2014-07-09 01:00:00')); 39 | }; 40 | 41 | done_testing; 42 | 43 | 44 | -------------------------------------------------------------------------------- /schema.sql: -------------------------------------------------------------------------------- 1 | 2 | CREATE TABLE IF NOT EXISTS metrics ( 3 | id INT UNSIGNED NOT NULL AUTO_INCREMENT, 4 | service_name VARCHAR(255) NOT NULL COLLATE utf8_bin, 5 | section_name VARCHAR(255) NOT NULL COLLATE utf8_bin, 6 | graph_name VARCHAR(255) NOT NULL COLLATE utf8_bin, 7 | sort INT UNSIGNED NOT NULL DEFAULT 0, 8 | meta TEXT NOT NULL, 9 | created_at DATETIME NOT NULL, 10 | updated_at TIMESTAMP NOT NULL, 11 | PRIMARY KEY (id), 12 | UNIQUE KEY (service_name, section_name, graph_name) 13 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8; 14 | 15 | CREATE TABLE IF NOT EXISTS data ( 16 | metrics_id INT UNSIGNED NOT NULL, 17 | datetime DATETIME NOT NULL, 18 | number BIGINT NOT NULL, 19 | updated_at TIMESTAMP NOT NULL, 20 | PRIMARY KEY (metrics_id, datetime), 21 | KEY (datetime) 22 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8; 23 | 24 | CREATE TABLE IF NOT EXISTS complex ( 25 | id INT UNSIGNED NOT NULL AUTO_INCREMENT, 26 | service_name VARCHAR(255) NOT NULL COLLATE utf8_bin, 27 | section_name VARCHAR(255) NOT NULL COLLATE utf8_bin, 28 | graph_name VARCHAR(255) NOT NULL COLLATE utf8_bin, 29 | sort INT UNSIGNED NOT NULL DEFAULT 0, 30 | meta TEXT NOT NULL, 31 | created_at DATETIME NOT NULL, 32 | updated_at TIMESTAMP NOT NULL, 33 | PRIMARY KEY (id), 34 | UNIQUE KEY (service_name, section_name, graph_name) 35 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8; 36 | 37 | -------------------------------------------------------------------------------- /t/HRForecast/Calculator/05_filter_by_from.t: -------------------------------------------------------------------------------- 1 | use strict; 2 | use warnings; 3 | use Test::More; 4 | use HRForecast; 5 | use HRForecast::Calculator; 6 | 7 | my $calculator = HRForecast::Calculator->new(); 8 | 9 | my $rows = [ 10 | {metrics_id => 1 , datetime => Time::Piece->strptime('2015-10-30', '%Y-%m-%d'), number => 1}, 11 | {metrics_id => 1 , datetime => Time::Piece->strptime('2015-10-31', '%Y-%m-%d'), number => 10}, 12 | {metrics_id => 1 , datetime => Time::Piece->strptime('2015-11-01', '%Y-%m-%d'), number => 100}, 13 | {metrics_id => 1 , datetime => Time::Piece->strptime('2015-11-02', '%Y-%m-%d'), number => 10}, 14 | {metrics_id => 1 , datetime => Time::Piece->strptime('2015-11-03', '%Y-%m-%d'), number => 1}, 15 | {metrics_id => 2 , datetime => Time::Piece->strptime('2015-10-30', '%Y-%m-%d'), number => 2}, 16 | {metrics_id => 2 , datetime => Time::Piece->strptime('2015-10-31', '%Y-%m-%d'), number => 20}, 17 | {metrics_id => 2 , datetime => Time::Piece->strptime('2015-11-01', '%Y-%m-%d'), number => 200}, 18 | {metrics_id => 2 , datetime => Time::Piece->strptime('2015-11-02', '%Y-%m-%d'), number => 20}, 19 | {metrics_id => 2 , datetime => Time::Piece->strptime('2015-11-03', '%Y-%m-%d'), number => 2}, 20 | ]; 21 | 22 | subtest 'filter_by_from' => sub { 23 | my $actual; 24 | 25 | $actual = $calculator->filter_by_from($rows, Time::Piece->strptime('2015-10-30', '%Y-%m-%d')); 26 | is(scalar(@$actual), 10); 27 | 28 | $actual = $calculator->filter_by_from($rows, Time::Piece->strptime('2015-11-01', '%Y-%m-%d')); 29 | is(scalar(@$actual), 6); 30 | }; 31 | 32 | done_testing; 33 | 34 | 35 | -------------------------------------------------------------------------------- /views/list.tx: -------------------------------------------------------------------------------- 1 | : cascade base 2 | 3 | : around content -> { 4 |

5 | Home » 6 | <: $c.args.service_name :> » 7 | <: $c.args.section_name :> 8 |

9 | 10 | : my $path_info = $c.req.path_info; 11 | 12 | 17 | 18 |
19 |
20 | f 21 | 30 |
31 |
32 | 33 |
34 | 35 | : for $metricses -> $metrics { 36 | : my $index = $~metrics.index; 37 | : if !$metrics.complex { 38 | : include "view.tx" { metrics => $metrics, index => $index }; 39 | : } else { 40 | : include "view_complex.tx" { metrics => $metrics, index => $index }; 41 | :} 42 | : } # for metricses 43 | 44 | : } 45 | 46 | 47 | -------------------------------------------------------------------------------- /views/pifr.tx: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 13 | 14 | 15 | 16 | 17 |
18 | 19 |
20 | 21 | : for $metricses -> $i_metrics { 22 | 23 | <: '/'~$i_metrics.service_name~'/'~$i_metrics.section_name~'/'~$i_metrics.graph_name :> 24 |
25 | : } 26 |
27 | 28 | 29 |
30 | 31 | 32 | 33 | 34 | 35 | 36 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /Build.PL: -------------------------------------------------------------------------------- 1 | # ========================================================================= 2 | # THIS FILE IS AUTOMATICALLY GENERATED BY MINILLA. 3 | # DO NOT EDIT DIRECTLY. 4 | # ========================================================================= 5 | 6 | use 5.008_001; 7 | 8 | use strict; 9 | use warnings; 10 | use utf8; 11 | 12 | use Module::Build; 13 | use File::Basename; 14 | use File::Spec; 15 | use CPAN::Meta; 16 | use CPAN::Meta::Prereqs; 17 | 18 | my %args = ( 19 | license => 'perl', 20 | dynamic_config => 0, 21 | 22 | configure_requires => { 23 | 'Module::Build' => 0.38, 24 | }, 25 | 26 | name => 'HRForecast', 27 | module_name => 'HRForecast', 28 | allow_pureperl => 0, 29 | 30 | script_files => [glob('script/*'), glob('bin/*')], 31 | c_source => [qw()], 32 | PL_files => {}, 33 | 34 | test_files => ((-d '.git' || $ENV{RELEASE_TESTING}) && -d 'xt') ? 't/ xt/' : 't/', 35 | recursive_test_files => 1, 36 | 37 | 38 | ); 39 | if (-d 'share') { 40 | $args{share_dir} = 'share'; 41 | } 42 | 43 | my $builder = Module::Build->subclass( 44 | class => 'MyBuilder', 45 | code => q{ 46 | sub ACTION_distmeta { 47 | die "Do not run distmeta. Install Minilla and `minil install` instead.\n"; 48 | } 49 | sub ACTION_installdeps { 50 | die "Do not run installdeps. Run `cpanm --installdeps .` instead.\n"; 51 | } 52 | } 53 | )->new(%args); 54 | $builder->create_build_script(); 55 | 56 | my $mbmeta = CPAN::Meta->load_file('MYMETA.json'); 57 | my $meta = CPAN::Meta->load_file('META.json'); 58 | my $prereqs_hash = CPAN::Meta::Prereqs->new( 59 | $meta->prereqs 60 | )->with_merged_prereqs( 61 | CPAN::Meta::Prereqs->new($mbmeta->prereqs) 62 | )->as_string_hash; 63 | my $mymeta = CPAN::Meta->new( 64 | { 65 | %{$meta->as_struct}, 66 | prereqs => $prereqs_hash 67 | } 68 | ); 69 | print "Merging cpanfile prereqs to MYMETA.yml\n"; 70 | $mymeta->save('MYMETA.yml', { version => 1.4 }); 71 | print "Merging cpanfile prereqs to MYMETA.json\n"; 72 | $mymeta->save('MYMETA.json', { version => 2 }); 73 | -------------------------------------------------------------------------------- /hrforecast.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl 2 | 3 | use strict; 4 | use warnings; 5 | use FindBin; 6 | use lib "$FindBin::Bin/extlib/lib/perl5"; 7 | use lib "$FindBin::Bin/lib"; 8 | use File::Basename; 9 | use Getopt::Long; 10 | use File::Temp qw/tempdir/; 11 | use Plack::Loader; 12 | use Plack::Builder; 13 | use Plack::Builder::Conditionals; 14 | use Plack::Middleware::Header; 15 | use Log::Minimal; 16 | use HRForecast; 17 | use HRForecast::Web; 18 | 19 | $Log::Minimal::AUTODUMP = 1; 20 | 21 | Getopt::Long::Configure ("no_ignore_case"); 22 | GetOptions( 23 | "c|config=s" => \my $config_file, 24 | "h|help" => \my $help, 25 | ); 26 | 27 | if ( $help || !$config_file ) { 28 | print "usage: $0 --config config.pl\n"; 29 | exit(1); 30 | } 31 | 32 | my $config; 33 | { 34 | $config = do $config_file; 35 | croakf "%s: %s", $config_file, $@ if $@; 36 | croakf "%s: %s", $config_file, $! if $!; 37 | croakf "%s does not return hashref", $config_file if ref($config) ne 'HASH'; 38 | } 39 | 40 | my $port = $config->{port} || 5125; 41 | my $host = $config->{host} || 0; 42 | my @front_proxy = exists $config->{front_proxy} ? @{$config->{front_proxy}} : (); 43 | my @allow_from = exists $config->{allow_from} ? @{$config->{allow_from}} : (); 44 | my @header = exists $config->{header} ? @{$config->{header}} : (); 45 | 46 | local $HRForecast::CONFIG = $config; 47 | debugf('dump config:%s',$config); 48 | 49 | my $root_dir = File::Basename::dirname(__FILE__); 50 | my $app = HRForecast::Web->psgi($root_dir); 51 | $app = builder { 52 | enable 'Lint'; 53 | enable 'StackTrace'; 54 | if ( @front_proxy ) { 55 | enable match_if addr(\@front_proxy), 'ReverseProxy'; 56 | } 57 | if ( @allow_from ) { 58 | enable match_if addr('!',\@allow_from), sub { 59 | sub { [403,['Content-Type','text/plain'], ['Forbidden']] } 60 | }; 61 | } 62 | if ( @header ) { 63 | enable 'Header', @header; 64 | } 65 | enable 'Static', 66 | path => qr!^/(?:(?:css|js|img|fonts)/|favicon\.ico$)!, 67 | root => $root_dir . '/public'; 68 | enable 'Scope::Container'; 69 | $app; 70 | }; 71 | my $loader = Plack::Loader->load( 72 | 'Starlet', 73 | port => $port, 74 | host => $host || 0, 75 | max_workers => 5, 76 | ); 77 | $loader->run($app); 78 | -------------------------------------------------------------------------------- /META.json: -------------------------------------------------------------------------------- 1 | { 2 | "abstract" : "A Graphing/Visualization Tool", 3 | "author" : [ 4 | "Masahiro Nagano C<< >>" 5 | ], 6 | "dynamic_config" : 0, 7 | "generated_by" : "Minilla/v0.8.4, CPAN::Meta::Converter version 2.132830", 8 | "license" : [ 9 | "unknown" 10 | ], 11 | "meta-spec" : { 12 | "url" : "http://search.cpan.org/perldoc?CPAN::Meta::Spec", 13 | "version" : "2" 14 | }, 15 | "name" : "HRForecast", 16 | "no_index" : { 17 | "directory" : [ 18 | "t", 19 | "xt", 20 | "inc", 21 | "share", 22 | "eg", 23 | "examples", 24 | "author", 25 | "builder" 26 | ] 27 | }, 28 | "prereqs" : { 29 | "configure" : { 30 | "requires" : { 31 | "CPAN::Meta" : "0", 32 | "CPAN::Meta::Prereqs" : "0", 33 | "Module::Build" : "0.38" 34 | } 35 | }, 36 | "develop" : { 37 | "requires" : { 38 | "Test::CPAN::Meta" : "0", 39 | "Test::MinimumVersion" : "0.10108", 40 | "Test::Pod" : "1.41", 41 | "Test::Spellunker" : "v0.2.7" 42 | } 43 | }, 44 | "runtime" : { 45 | "requires" : { 46 | "DBD::mysql" : "4.018", 47 | "DBIx::Sunny" : "0.14", 48 | "HTTP::Date" : "0", 49 | "Kossy" : "0.27", 50 | "List::MoreUtils" : "0", 51 | "Log::Minimal" : "0.09", 52 | "Parallel::Scoreboard" : "0.03", 53 | "Plack::Builder::Conditionals" : "0.03", 54 | "Plack::Middleware::Header" : "0.04", 55 | "Plack::Middleware::Scope::Container" : "0.01", 56 | "Scope::Container::DBI" : "0.03", 57 | "Starlet" : "0.20", 58 | "Time::Piece" : "1.15", 59 | "Time::Piece::MySQL" : "0.06" 60 | } 61 | } 62 | }, 63 | "release_status" : "unstable", 64 | "resources" : { 65 | "bugtracker" : { 66 | "web" : "https://github.com/kazeburo/HRForecast/issues" 67 | }, 68 | "homepage" : "https://github.com/kazeburo/HRForecast", 69 | "repository" : { 70 | "type" : "git", 71 | "url" : "git://github.com/kazeburo/HRForecast.git", 72 | "web" : "https://github.com/kazeburo/HRForecast" 73 | } 74 | }, 75 | "version" : "0.01", 76 | "x_contributors" : [ 77 | "issm ", 78 | "Masayuki Matsuki ", 79 | "Spring_MT ", 80 | "hitode909 ", 81 | "Daisuke Maki ", 82 | "shiba_yu36 ", 83 | "Akiyah ", 84 | "Masahiro Nagano " 85 | ] 86 | } 87 | -------------------------------------------------------------------------------- /t/HRForecast/Calculator/02_calculate.t: -------------------------------------------------------------------------------- 1 | use strict; 2 | use warnings; 3 | use Test::More; 4 | use Test::MockObject; 5 | use Time::Piece; 6 | use Time::Seconds; 7 | use HRForecast; 8 | use HRForecast::Calculator; 9 | use Data::Dumper; 10 | 11 | my $calculator = HRForecast::Calculator->new(); 12 | 13 | subtest 'calculate(identity)' => sub { 14 | my $from = Time::Piece->strptime('2015-10-30', '%Y-%m-%d'); 15 | my $to = Time::Piece->strptime('2015-11-03', '%Y-%m-%d'); 16 | 17 | my @rows_of_get_data = (); 18 | for (my $i = 0; $i < 5; $i++) { 19 | push @rows_of_get_data, {metrics_id=>1, datetime=>$from + ONE_DAY * $i, number=>10}; 20 | push @rows_of_get_data, {metrics_id=>2, datetime=>$from + ONE_DAY * $i, number=>20}; 21 | } 22 | 23 | my $data = Test::MockObject->new; 24 | $data->mock('get_data' => sub { 25 | my ($self, $id, $from, $to) = @_; 26 | is(123, $id); 27 | is('2015-10-30', $from->ymd); 28 | is('2015-11-03', $to->ymd); 29 | return \@rows_of_get_data; 30 | }); 31 | 32 | my $expected = \@rows_of_get_data; 33 | my $actual = $calculator->calculate($data, 123, $from, $to, ''); 34 | is_deeply($actual, $expected); 35 | }; 36 | 37 | subtest 'calculate(runningtotal_by_month)' => sub { 38 | my $from = Time::Piece->strptime('2015-10-30', '%Y-%m-%d'); 39 | my $to = Time::Piece->strptime('2015-11-03', '%Y-%m-%d'); 40 | 41 | my @rows_of_get_data = (); 42 | for (my $i = -31; $i < 5; $i++) { 43 | push @rows_of_get_data, {metrics_id=>1, datetime=>$from + ONE_DAY * $i, number=>10}; 44 | push @rows_of_get_data, {metrics_id=>2, datetime=>$from + ONE_DAY * $i, number=>20}; 45 | } 46 | 47 | my $data = Test::MockObject->new; 48 | $data->mock('get_data' => sub { 49 | my ($self, $id, $from, $to) = @_; 50 | is(123, $id); 51 | is('2015-09-29', $from->ymd); 52 | is('2015-11-03', $to->ymd); 53 | return \@rows_of_get_data; 54 | }); 55 | 56 | my $expected = [ 57 | {metrics_id=>1, datetime=>Time::Piece->strptime('2015-10-30', '%Y-%m-%d'), number=> 300}, 58 | {metrics_id=>2, datetime=>Time::Piece->strptime('2015-10-30', '%Y-%m-%d'), number=> 600}, 59 | {metrics_id=>1, datetime=>Time::Piece->strptime('2015-10-31', '%Y-%m-%d'), number=> 310}, 60 | {metrics_id=>2, datetime=>Time::Piece->strptime('2015-10-31', '%Y-%m-%d'), number=> 620}, 61 | {metrics_id=>1, datetime=>Time::Piece->strptime('2015-11-01', '%Y-%m-%d'), number=> 10}, 62 | {metrics_id=>2, datetime=>Time::Piece->strptime('2015-11-01', '%Y-%m-%d'), number=> 20}, 63 | {metrics_id=>1, datetime=>Time::Piece->strptime('2015-11-02', '%Y-%m-%d'), number=> 20}, 64 | {metrics_id=>2, datetime=>Time::Piece->strptime('2015-11-02', '%Y-%m-%d'), number=> 40}, 65 | {metrics_id=>1, datetime=>Time::Piece->strptime('2015-11-03', '%Y-%m-%d'), number=> 30}, 66 | {metrics_id=>2, datetime=>Time::Piece->strptime('2015-11-03', '%Y-%m-%d'), number=> 60}, 67 | ]; 68 | 69 | my $actual = $calculator->calculate($data, 123, $from, $to, 'runningtotal_by_month'); 70 | is_deeply($actual, $expected); 71 | }; 72 | 73 | done_testing; 74 | 75 | 76 | -------------------------------------------------------------------------------- /views/edit.tx: -------------------------------------------------------------------------------- 1 | : cascade base 2 | 3 | : around additonal_meta -> { 4 | 5 | : } 6 | 7 | : around content -> { 8 |

グラフ設定変更

9 | 10 | : block form | fillinform( $stash.metrics ) -> { 11 |
12 | 13 |
14 | /<: $c.stash.metrics.service_name :>/<: $c.stash.metrics.section_name :>/<: $c.stash.metrics.graph_name :> 15 | 16 |
17 | 18 |
19 |
20 | / 21 | 22 | / 23 | 24 | / 25 | 26 |
27 |

グラフを表示するURI(サービス名/セクション名/グラフ名)

28 |

29 |
30 |
31 | 32 |
33 | 34 |
35 | 36 |

37 |
38 |
39 | 40 |
41 | 42 |
43 | 65 |

数値が大きい方が先頭

66 |

67 |
68 |
69 | 70 | 71 |
72 | 73 |
74 | 75 |

#000000-#FFFFFFの範囲

76 |

77 |
78 |
79 | 80 |
81 |
82 | 83 | キャンセル 84 | 85 |
86 |
87 | 88 |
89 |
90 | : } #fillin 91 | 92 | : } 93 | -------------------------------------------------------------------------------- /views/view_complex.tx: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 |
5 |

<: $metrics.graph_name :> <: $metrics.description :>

6 |
7 |
8 | 34 |
35 |
36 | 37 | 43 | 44 |
45 |
46 | 47 | : for $metrics.metricses -> $i_metrics { 48 | 49 | <: '/'~$i_metrics.service_name~'/'~$i_metrics.section_name~'/'~$i_metrics.graph_name :> 50 |
51 | : } 52 |
53 | 54 | 55 |   56 |
57 |
58 |
59 | 60 |
61 | -------------------------------------------------------------------------------- /public/js/jquery.cookie.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * jQuery Cookie Plugin v1.4.0 3 | * https://github.com/carhartl/jquery-cookie 4 | * 5 | * Copyright 2013 Klaus Hartl 6 | * Released under the MIT license 7 | */ 8 | (function (factory) { 9 | if (typeof define === 'function' && define.amd) { 10 | // AMD. Register as anonymous module. 11 | define(['jquery'], factory); 12 | } else { 13 | // Browser globals. 14 | factory(jQuery); 15 | } 16 | }(function ($) { 17 | 18 | var pluses = /\+/g; 19 | 20 | function encode(s) { 21 | return config.raw ? s : encodeURIComponent(s); 22 | } 23 | 24 | function decode(s) { 25 | return config.raw ? s : decodeURIComponent(s); 26 | } 27 | 28 | function stringifyCookieValue(value) { 29 | return encode(config.json ? JSON.stringify(value) : String(value)); 30 | } 31 | 32 | function parseCookieValue(s) { 33 | if (s.indexOf('"') === 0) { 34 | // This is a quoted cookie as according to RFC2068, unescape... 35 | s = s.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\'); 36 | } 37 | 38 | try { 39 | // Replace server-side written pluses with spaces. 40 | // If we can't decode the cookie, ignore it, it's unusable. 41 | // If we can't parse the cookie, ignore it, it's unusable. 42 | s = decodeURIComponent(s.replace(pluses, ' ')); 43 | return config.json ? JSON.parse(s) : s; 44 | } catch(e) {} 45 | } 46 | 47 | function read(s, converter) { 48 | var value = config.raw ? s : parseCookieValue(s); 49 | return $.isFunction(converter) ? converter(value) : value; 50 | } 51 | 52 | var config = $.cookie = function (key, value, options) { 53 | 54 | // Write 55 | if (value !== undefined && !$.isFunction(value)) { 56 | options = $.extend({}, config.defaults, options); 57 | 58 | if (typeof options.expires === 'number') { 59 | var days = options.expires, t = options.expires = new Date(); 60 | t.setDate(t.getDate() + days); 61 | } 62 | 63 | return (document.cookie = [ 64 | encode(key), '=', stringifyCookieValue(value), 65 | options.expires ? '; expires=' + options.expires.toUTCString() : '', // use expires attribute, max-age is not supported by IE 66 | options.path ? '; path=' + options.path : '', 67 | options.domain ? '; domain=' + options.domain : '', 68 | options.secure ? '; secure' : '' 69 | ].join('')); 70 | } 71 | 72 | // Read 73 | 74 | var result = key ? undefined : {}; 75 | 76 | // To prevent the for loop in the first place assign an empty array 77 | // in case there are no cookies at all. Also prevents odd result when 78 | // calling $.cookie(). 79 | var cookies = document.cookie ? document.cookie.split('; ') : []; 80 | 81 | for (var i = 0, l = cookies.length; i < l; i++) { 82 | var parts = cookies[i].split('='); 83 | var name = decode(parts.shift()); 84 | var cookie = parts.join('='); 85 | 86 | if (key && key === name) { 87 | // If second argument (value) is a function it's a converter... 88 | result = read(cookie, value); 89 | break; 90 | } 91 | 92 | // Prevent storing a cookie that we couldn't decode. 93 | if (!key && (cookie = read(cookie)) !== undefined) { 94 | result[name] = cookie; 95 | } 96 | } 97 | 98 | return result; 99 | }; 100 | 101 | config.defaults = {}; 102 | 103 | $.removeCookie = function (key, options) { 104 | if ($.cookie(key) === undefined) { 105 | return false; 106 | } 107 | 108 | // Must not alter options, thus extending a fresh object... 109 | $.cookie(key, '', $.extend({}, options, { expires: -1 })); 110 | return !$.cookie(key); 111 | }; 112 | 113 | })); 114 | -------------------------------------------------------------------------------- /views/view.tx: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 |
5 |

<: $metrics.graph_name :> <: $metrics.description :>

6 |
7 |
8 | 34 |
35 |
36 | 37 | 43 | 44 | 57 | 58 |
59 | -------------------------------------------------------------------------------- /lib/HRForecast/Calculator.pm: -------------------------------------------------------------------------------- 1 | package HRForecast::Calculator; 2 | 3 | use strict; 4 | use warnings; 5 | use utf8; 6 | use HTTP::Date; 7 | use Time::Piece; 8 | use Time::Seconds; 9 | use Data::Dumper; 10 | 11 | use constant CALCULATIONS => [ 12 | {function=>'', name=>'———', minus_days_of_from => 0}, 13 | {function=>'runningtotal', name=>'累計', minus_days_of_from => 0}, 14 | {function=>'runningtotal_by_month', name=>'累計(月別)', minus_days_of_from => 31}, 15 | {function=>'difference', name=>'差分', minus_days_of_from => 1}, 16 | {function=>'difference_plus', name=>'差分(増加のみ)', minus_days_of_from => 1}, 17 | ]; 18 | 19 | sub new { 20 | my $class = shift; 21 | bless {}, $class; 22 | } 23 | 24 | sub calculate { 25 | my ($self, $data, $id, $from, $to, $calculation) = @_; 26 | 27 | my $minus_days_of_from = $self->find_minus_days_of_from($calculation); 28 | my $from_extended = $from - ONE_DAY * $minus_days_of_from; 29 | 30 | my ($rows, $opt) = $data->get_data($id, $from_extended, $to); 31 | 32 | my $function_name = 'calculation_' . $calculation; 33 | my $rows_calculated = $self->$function_name($rows); 34 | my $rows_filtered = $self->filter_by_from($rows_calculated, $from); 35 | 36 | return $rows_filtered; 37 | } 38 | 39 | sub find_minus_days_of_from { 40 | my ($self, $calculation) = @_; 41 | 42 | foreach my $c ( @{CALCULATIONS()} ) { 43 | return $c->{minus_days_of_from} if $c->{function} eq $calculation; 44 | } 45 | 46 | return 0; 47 | } 48 | 49 | sub filter_by_from { 50 | my ($self, $rows, $from) = @_; 51 | 52 | my @rows_filtered = grep { $_->{datetime} >= $from } @$rows; 53 | return \@rows_filtered; 54 | } 55 | 56 | sub calculation_ { 57 | my ($self, $rows) = @_; 58 | 59 | return $rows; 60 | } 61 | 62 | sub calculation_runningtotal { 63 | my ($self, $rows) = @_; 64 | 65 | return $self->calculation_runningtotal_by($rows, sub {}); 66 | } 67 | 68 | sub calculation_runningtotal_by_month { 69 | my ($self, $rows) = @_; 70 | 71 | return $self->calculation_runningtotal_by($rows, sub { 72 | my ($datetime, $last_datetime) = @_; 73 | my $month = $datetime->strftime("%Y/%m"); 74 | my $last_month = $last_datetime->strftime("%Y/%m"); 75 | $month ne $last_month; 76 | }); 77 | } 78 | 79 | sub calculation_runningtotal_by { 80 | my ($self, $rows, $by_function) = @_; 81 | 82 | my @calculated_rows; 83 | my %number; 84 | my %last_datetime; 85 | my $metrics_id; 86 | foreach my $row ( @$rows ) { 87 | $metrics_id = $row->{metrics_id}; 88 | $number{$metrics_id} ||= 0; 89 | my $datetime = $row->{datetime}; 90 | if ((exists $last_datetime{$metrics_id}) and $by_function->($datetime, $last_datetime{$metrics_id})) { 91 | $number{$metrics_id} = 0; 92 | } 93 | $number{$metrics_id} += $row->{number}; 94 | push @calculated_rows, { 95 | metrics_id => $row->{metrics_id}, 96 | datetime => $row->{datetime}, 97 | number => $number{$metrics_id} 98 | }; 99 | $last_datetime{$metrics_id} = $datetime; 100 | } 101 | 102 | return \@calculated_rows; 103 | } 104 | 105 | sub calculation_difference { 106 | my ($self, $rows) = @_; 107 | 108 | my @calculated_rows; 109 | my $number; 110 | my %last_number; 111 | my $metrics_id; 112 | foreach my $row ( @$rows ) { 113 | $number = $row->{number}; 114 | $metrics_id = $row->{metrics_id}; 115 | push @calculated_rows, { 116 | metrics_id => $row->{metrics_id}, 117 | datetime => $row->{datetime}, 118 | number => exists($last_number{$metrics_id}) ? $number - $last_number{$metrics_id} : 0 119 | }; 120 | $last_number{$metrics_id} = $number; 121 | } 122 | 123 | return \@calculated_rows; 124 | } 125 | 126 | sub calculation_difference_plus { 127 | my ($self, $rows) = @_; 128 | 129 | $rows = $self->calculation_difference($rows); 130 | 131 | foreach my $row ( @$rows ) { 132 | if ($row->{number} < 0) { 133 | $row->{number} = 0; 134 | } 135 | } 136 | 137 | return $rows; 138 | } 139 | 140 | 1; 141 | 142 | -------------------------------------------------------------------------------- /views/docs.tx: -------------------------------------------------------------------------------- 1 | : cascade base 2 | 3 | : around additonal_meta -> { 4 | 5 | : } 6 | 7 | : around content -> { 8 |

ドキュメント的な。

9 | 10 |
11 | 12 |

グラフの登録方法

13 | 14 |

以下の URL を POST メソッドで叩いてください。

15 | 16 |
 17 | <: $c.req.uri_for('/') :>api/:service_name/:section_name/:graph_name
 18 | 
19 | 20 |

HRForecast は、多数のサービスで利用可能な共通 Web Graph API を目標として作られています。 URL 中の各名前に関しては下の表を参考にしてください。

21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 |
例中の名前役割具体例を , 区切りで
:service_nameグラフを取りたいサービスの名前hatenablog, ficia, loctouch, ninjyatoriai
:section_nameそのサービスの中での、グラフを取る対象が属してる機能やシステム名entry, user, spot, items
:graph_name具体的に何のグラフかtotal_entry, kakin_user, muryo_user, syuriken_no_ureta_kazu
44 | 45 |

もし、忍者取り合いっていうサービスのアイテムの中の手裏剣が売りたい数だったら

46 | 47 |
 48 | <: $c.req.uri_for('/') :>api/ninjyatoriai/items/syuriken_no_ureta_kazu
 49 | 
50 | 51 |

に対して POST します。

52 | 53 |

また、 POST する時には以下のパラメータをつけます。

54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 91 | 92 |
パラメータ説明必須/オプション
numberグラフに与える数値必須
datetime数値に関する日付
69 | サポートするフォーマット 70 |
 71 | "Wed, 09 Feb 1994 22:23:32 GMT"       -- HTTP format
 72 | "Thu Feb  3 17:03:55 GMT 1994"        -- ctime(3) format
 73 | "Thu Feb  3 00:00:00 1994",           -- ANSI C asctime() format
 74 | "Tuesday, 08-Feb-94 14:15:29 GMT"     -- old rfc850 HTTP format
 75 | "Tuesday, 08-Feb-1994 14:15:29 GMT"   -- broken rfc850 HTTP format
 76 | "03/Feb/1994:17:03:55 -0700"   -- common logfile format
 77 | "09 Feb 1994 22:23:32 GMT"     -- HTTP format (no weekday)
 78 | "08-Feb-94 14:15:29 GMT"       -- rfc850 format (no weekday)
 79 | "08-Feb-1994 14:15:29 GMT"     -- broken rfc850 format (no weekday)
 80 | "1994-02-03 14:15:29 -0100"    -- ISO 8601 format
 81 | "1994-02-03 14:15:29"          -- zone is optional
 82 | "1994-02-03"                   -- only date
 83 | "1994-02-03T14:15:29"          -- Use T as separator
 84 | "19940203T141529Z"             -- ISO 8601 compact format
 85 | "19940203"                     -- only date
 86 | 
87 | 88 | ただし、1時間未満は切り捨てられます。
89 | 例) 1994-02-03T14:15:29 => 1994-02-03T14:00:00
90 |
必須
93 | 94 |

LWP::UserAgent を使うと以下の様になります。

95 | 96 |
 97 | my $ua = LWP::UserAgent->new;
 98 | $ua->post('<: $c.req.uri_for('/') :>api/ninjyatoriai/items/syuriken_no_ureta_kazu', {
 99 |     number      => 10,
100 |     datetime    => scalar localtime(),
101 | });
102 | 
103 | 104 |

curl を使うと以下の様になります。

105 | 106 |
107 | $ curl -F number=10 -F datetime=20120206T09:41:31 <: $c.req.uri_for('/') :>api/ninjyatoriai/items/syuriken_no_ureta_kazu
108 | 
109 | 110 |
111 | 112 |

グラフの表示オプション

113 | 114 |

たぶんiframeで呼び出し可能です

115 | 116 |
117 | <iframe src="<: $c.req.uri_for('/') :>ifr/:service_name/:section_name/:graph_name" width="425" height="355" frameborder="0" marginwidth="0" marginheight="0" scrolling="no"></iframe>
118 | 
119 | <iframe src="<: $c.req.uri_for('/') :>ifr_complex/:service_name/:section_name/:graph_name" width="425" height="355" frameborder="0" marginwidth="0" marginheight="0" scrolling="no"></iframe> (複合グラフの場合)
120 | 
121 | 122 |

srcのURIパラメータとして、以下が利用できます

123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 139 | 140 | 141 | 142 | 143 | 144 | 147 | 148 | 149 | 150 | 151 | 152 | 159 | 160 | 161 | 162 |
パラメータ説明必須/オプション
tグラフの表示範囲
134 | w => 1週間
135 | m => 1ヶ月
136 | y => 1年
137 | c => 指定
138 |
オプション。デフォルトは「m」
from/toグラフの表示範囲を「c」にした場合の日付
145 | データ登録APIと同じ日付フォーマットが利用できる 146 |
オプション
calculationグラフの表示形式
153 | 無指定 => そのまま表示
154 | runningtotal => 累計表示
155 | runningtotal_by_month => 月別累計表示
156 | difference => 差分表示
157 | difference_plus => 増加分のみの差分表示
158 |
オプション
163 | 164 | : } 165 | -------------------------------------------------------------------------------- /views/base.tx: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | : block additonal_meta -> { } 6 | 7 | 8 | 9 | HRForecast 10 | 38 | 39 | 40 | 41 | 61 | 62 | 63 |
64 |
65 |
66 | 67 |
68 | : for $c.stash.services -> $service { 69 |
70 |
71 |

72 | <: $service.name :> 73 |

74 |
75 | 80 |
81 | : } 82 |
83 | 84 |
85 | 86 |
87 | : block content -> { } 88 |
89 |
90 | 91 |
92 | 93 |
94 | 95 | 96 | 97 | 98 | 99 | 100 | 120 | 121 | 122 | 123 | -------------------------------------------------------------------------------- /views/ifr_complex.tx: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 13 | 14 | HRForecast 15 | 16 | 17 | 18 | : if $valid.valid('graphheader') != 0 { 19 |

<: $metrics.graph_name :> <: $metrics.description :>

20 | 21 | 22 | 47 | : } 48 | 49 |
50 | 51 | : if $valid.valid('graphlabel') != 0 { 52 |
53 | 54 | : for $metrics.metricses -> $i_metrics { 55 | 56 | <: '/'~$i_metrics.service_name~'/'~$i_metrics.section_name~'/'~$i_metrics.graph_name :> 57 |
58 | : } 59 |
60 | 61 | 62 |
63 | : } 64 | 65 | 66 | 67 | 68 | 73 | 74 | 75 | 76 | -------------------------------------------------------------------------------- /views/ifr.tx: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 13 | 14 | HRForecast 15 | 16 | 17 | 18 | : if $valid.valid('graphheader') != 0 { 19 |

<: $metrics.graph_name :> <: $metrics.description :>

20 | 21 | 22 | 47 | : } 48 | 49 |
50 | 51 | : if $valid.valid('graphlabel') != 0 { 52 | 61 | : } 62 | 63 | 64 | 65 | 66 | 71 | 72 | 73 | -------------------------------------------------------------------------------- /views/add_complex.tx: -------------------------------------------------------------------------------- 1 | : cascade base 2 | 3 | : around additonal_meta -> { 4 | 5 | : } 6 | 7 | : around content -> { 8 |

複合グラフ追加

9 | 10 |
11 | 12 |
13 | 14 | グラフの情報 15 | 16 |
17 | 18 |
19 |
20 | / 21 | 22 | / 23 | 24 | / 25 | 26 |
27 |

グラフを表示するURI(サービス名/セクション名/グラフ名)

28 |

29 |
30 |
31 | 32 |
33 | 34 |
35 | 36 |

37 |
38 |
39 | 40 |
41 | 42 |
43 | 65 |

数値が大きい方が先頭

66 |

67 |
68 |
69 | 70 |
71 | 72 |
73 | 77 |

グラフを積み重ねるか

78 |

79 |
80 |
81 | 82 |
83 | 84 |
85 | 94 | / 95 | 102 |
103 | 104 |
105 | グラフデータ 106 | 107 |
108 | 109 |
110 |
111 | / 112 | 119 | / 120 | 122 | / 123 | 125 | 126 | 127 | 128 |
129 |

表示するデータを選択して「追加」ボタンを押してください

130 |
131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 |
 パス削除
139 |
140 | 141 |
142 | 143 |
144 | グラフのプレビュー 145 | 146 |
147 | 148 |
149 | 150 |
151 |
152 |
153 | 154 |
155 |
156 |
157 | 158 |
159 | 160 | : } 161 | 162 | -------------------------------------------------------------------------------- /t/HRForecast/Calculator/03_calculation.t: -------------------------------------------------------------------------------- 1 | use strict; 2 | use warnings; 3 | use Test::More; 4 | use HRForecast; 5 | use HRForecast::Calculator; 6 | 7 | my $calculator = HRForecast::Calculator->new(); 8 | 9 | my $input_rows = [ 10 | {metrics_id => 1 , datetime => Time::Piece->strptime('2015-10-30', '%Y-%m-%d'), number => 1}, 11 | {metrics_id => 1 , datetime => Time::Piece->strptime('2015-10-31', '%Y-%m-%d'), number => 10}, 12 | {metrics_id => 1 , datetime => Time::Piece->strptime('2015-11-01', '%Y-%m-%d'), number => 100}, 13 | {metrics_id => 1 , datetime => Time::Piece->strptime('2015-11-02', '%Y-%m-%d'), number => 10}, 14 | {metrics_id => 1 , datetime => Time::Piece->strptime('2015-11-03', '%Y-%m-%d'), number => 1}, 15 | {metrics_id => 2 , datetime => Time::Piece->strptime('2015-10-30', '%Y-%m-%d'), number => 2}, 16 | {metrics_id => 2 , datetime => Time::Piece->strptime('2015-10-31', '%Y-%m-%d'), number => 20}, 17 | {metrics_id => 2 , datetime => Time::Piece->strptime('2015-11-01', '%Y-%m-%d'), number => 200}, 18 | {metrics_id => 2 , datetime => Time::Piece->strptime('2015-11-02', '%Y-%m-%d'), number => 20}, 19 | {metrics_id => 2 , datetime => Time::Piece->strptime('2015-11-03', '%Y-%m-%d'), number => 2}, 20 | ]; 21 | 22 | subtest 'calculation=' => sub { 23 | my $expected = $input_rows; 24 | 25 | my $actual = $calculator->calculation_($input_rows); 26 | is_deeply($actual, $expected); 27 | }; 28 | 29 | subtest 'calculation=runningtotal' => sub { 30 | my $expected = [ 31 | {metrics_id => 1 , datetime => Time::Piece->strptime('2015-10-30', '%Y-%m-%d'), number => 1}, 32 | {metrics_id => 1 , datetime => Time::Piece->strptime('2015-10-31', '%Y-%m-%d'), number => 11}, 33 | {metrics_id => 1 , datetime => Time::Piece->strptime('2015-11-01', '%Y-%m-%d'), number => 111}, 34 | {metrics_id => 1 , datetime => Time::Piece->strptime('2015-11-02', '%Y-%m-%d'), number => 121}, 35 | {metrics_id => 1 , datetime => Time::Piece->strptime('2015-11-03', '%Y-%m-%d'), number => 122}, 36 | {metrics_id => 2 , datetime => Time::Piece->strptime('2015-10-30', '%Y-%m-%d'), number => 2}, 37 | {metrics_id => 2 , datetime => Time::Piece->strptime('2015-10-31', '%Y-%m-%d'), number => 22}, 38 | {metrics_id => 2 , datetime => Time::Piece->strptime('2015-11-01', '%Y-%m-%d'), number => 222}, 39 | {metrics_id => 2 , datetime => Time::Piece->strptime('2015-11-02', '%Y-%m-%d'), number => 242}, 40 | {metrics_id => 2 , datetime => Time::Piece->strptime('2015-11-03', '%Y-%m-%d'), number => 244}, 41 | ]; 42 | 43 | my $actual = $calculator->calculation_runningtotal($input_rows); 44 | is_deeply($actual, $expected); 45 | }; 46 | 47 | subtest 'calculation=runningtotal_by_month' => sub { 48 | my $expected = [ 49 | {metrics_id => 1 , datetime => Time::Piece->strptime('2015-10-30', '%Y-%m-%d'), number => 1}, 50 | {metrics_id => 1 , datetime => Time::Piece->strptime('2015-10-31', '%Y-%m-%d'), number => 11}, 51 | {metrics_id => 1 , datetime => Time::Piece->strptime('2015-11-01', '%Y-%m-%d'), number => 100}, #月が変わったのでリセット 52 | {metrics_id => 1 , datetime => Time::Piece->strptime('2015-11-02', '%Y-%m-%d'), number => 110}, 53 | {metrics_id => 1 , datetime => Time::Piece->strptime('2015-11-03', '%Y-%m-%d'), number => 111}, 54 | {metrics_id => 2 , datetime => Time::Piece->strptime('2015-10-30', '%Y-%m-%d'), number => 2}, 55 | {metrics_id => 2 , datetime => Time::Piece->strptime('2015-10-31', '%Y-%m-%d'), number => 22}, 56 | {metrics_id => 2 , datetime => Time::Piece->strptime('2015-11-01', '%Y-%m-%d'), number => 200}, #月が変わったのでリセット 57 | {metrics_id => 2 , datetime => Time::Piece->strptime('2015-11-02', '%Y-%m-%d'), number => 220}, 58 | {metrics_id => 2 , datetime => Time::Piece->strptime('2015-11-03', '%Y-%m-%d'), number => 222}, 59 | ]; 60 | 61 | my $actual = $calculator->calculation_runningtotal_by_month($input_rows); 62 | is_deeply($actual, $expected); 63 | }; 64 | 65 | subtest 'calculation=difference' => sub { 66 | my $expected = [ 67 | {metrics_id => 1 , datetime => Time::Piece->strptime('2015-10-30', '%Y-%m-%d'), number => 0}, 68 | {metrics_id => 1 , datetime => Time::Piece->strptime('2015-10-31', '%Y-%m-%d'), number => 9}, 69 | {metrics_id => 1 , datetime => Time::Piece->strptime('2015-11-01', '%Y-%m-%d'), number => 90}, 70 | {metrics_id => 1 , datetime => Time::Piece->strptime('2015-11-02', '%Y-%m-%d'), number => -90}, 71 | {metrics_id => 1 , datetime => Time::Piece->strptime('2015-11-03', '%Y-%m-%d'), number => -9}, 72 | {metrics_id => 2 , datetime => Time::Piece->strptime('2015-10-30', '%Y-%m-%d'), number => 0}, 73 | {metrics_id => 2 , datetime => Time::Piece->strptime('2015-10-31', '%Y-%m-%d'), number => 18}, 74 | {metrics_id => 2 , datetime => Time::Piece->strptime('2015-11-01', '%Y-%m-%d'), number => 180}, 75 | {metrics_id => 2 , datetime => Time::Piece->strptime('2015-11-02', '%Y-%m-%d'), number =>-180}, 76 | {metrics_id => 2 , datetime => Time::Piece->strptime('2015-11-03', '%Y-%m-%d'), number => -18}, 77 | ]; 78 | 79 | my $actual = $calculator->calculation_difference($input_rows); 80 | is_deeply($actual, $expected); 81 | }; 82 | 83 | subtest 'calculation=difference_plus' => sub { 84 | my $expected = [ 85 | {metrics_id => 1 , datetime => Time::Piece->strptime('2015-10-30', '%Y-%m-%d'), number => 0}, 86 | {metrics_id => 1 , datetime => Time::Piece->strptime('2015-10-31', '%Y-%m-%d'), number => 9}, 87 | {metrics_id => 1 , datetime => Time::Piece->strptime('2015-11-01', '%Y-%m-%d'), number => 90}, 88 | {metrics_id => 1 , datetime => Time::Piece->strptime('2015-11-02', '%Y-%m-%d'), number => 0}, 89 | {metrics_id => 1 , datetime => Time::Piece->strptime('2015-11-03', '%Y-%m-%d'), number => 0}, 90 | {metrics_id => 2 , datetime => Time::Piece->strptime('2015-10-30', '%Y-%m-%d'), number => 0}, 91 | {metrics_id => 2 , datetime => Time::Piece->strptime('2015-10-31', '%Y-%m-%d'), number => 18}, 92 | {metrics_id => 2 , datetime => Time::Piece->strptime('2015-11-01', '%Y-%m-%d'), number => 180}, 93 | {metrics_id => 2 , datetime => Time::Piece->strptime('2015-11-02', '%Y-%m-%d'), number => 0}, 94 | {metrics_id => 2 , datetime => Time::Piece->strptime('2015-11-03', '%Y-%m-%d'), number => 0}, 95 | ]; 96 | 97 | my $actual = $calculator->calculation_difference_plus($input_rows); 98 | is_deeply($actual, $expected); 99 | }; 100 | 101 | done_testing; 102 | 103 | 104 | -------------------------------------------------------------------------------- /views/edit_complex.tx: -------------------------------------------------------------------------------- 1 | : cascade base 2 | 3 | : around additonal_meta -> { 4 | 5 | : } 6 | 7 | : around content -> { 8 |

複合グラフ編集

9 | 10 |
11 | 12 | : block form | fillinform( $c.stash.metrics ) -> { 13 | 14 |
15 | グラフの情報 16 | 17 |
18 | 19 |
20 |
21 | / 22 | 23 | / 24 | 25 | / 26 | 27 |
28 |

グラフを表示するURI(サービス名/セクション名/グラフ名)

29 |

30 |
31 |
32 | 33 |
34 | 35 |
36 | 37 |

38 |
39 |
40 | 41 |
42 | 43 |
44 | 66 |

数値が大きい方が先頭

67 |

68 |
69 |
70 | 71 |
72 | 73 |
74 | 78 |

グラフを積み重ねるか

79 |

80 |
81 |
82 | 83 |
84 | : } 85 | 86 |
87 | 96 | / 97 | 104 |
105 | 106 |
107 | グラフデータ 108 | 109 |
110 |
111 | / 112 | 119 | / 120 | 122 | / 123 | 125 | 126 | 127 | 128 |
129 |

表示するデータを選択して「追加」ボタンを押してください

130 |
131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | : for $stash.metrics.metricses -> $metrics { 139 | 140 | 141 | 142 | 143 | 144 | : } 145 |
 パス削除
/<: $metrics.service_name :>/<: $metrics.section_name :>/<: $metrics.graph_name :>
146 | 147 | 148 | 149 |
150 | 151 |
152 | グラフのプレビュー 153 | 154 |
155 | 156 |
157 | 158 |
159 |
160 |
161 | 162 | キャンセル 163 | 164 |
165 |
166 |
167 | 168 | 169 |
170 | 171 | : } 172 | -------------------------------------------------------------------------------- /lib/HRForecast/Data.pm: -------------------------------------------------------------------------------- 1 | package HRForecast::Data; 2 | 3 | 4 | use strict; 5 | use warnings; 6 | use utf8; 7 | use Time::Piece; 8 | use Time::Piece::MySQL; 9 | use JSON qw//; 10 | use Log::Minimal; 11 | use DBIx::Sunny; 12 | use Scope::Container::DBI; 13 | use List::MoreUtils qw/uniq/; 14 | use List::Util qw/first/; 15 | 16 | my $JSON = JSON->new()->ascii(1); 17 | sub encode_json { 18 | $JSON->encode(shift); 19 | } 20 | 21 | sub new { 22 | my $class = shift; 23 | bless {}, $class; 24 | } 25 | 26 | sub dbh { 27 | my $self = shift; 28 | local $Scope::Container::DBI::DBI_CLASS = 'DBIx::Sunny'; 29 | Scope::Container::DBI->connect( 30 | HRForecast->config->{dsn}, 31 | HRForecast->config->{username}, 32 | HRForecast->config->{password} 33 | ); 34 | } 35 | 36 | sub round_interval { 37 | HRForecast->config->{round_interval} || 3600; 38 | } 39 | 40 | sub inflate_row { 41 | my ($self, $row) = @_; 42 | $row->{created_at} = Time::Piece->from_mysql_datetime($row->{created_at}); 43 | $row->{updated_at} = Time::Piece->from_mysql_timestamp($row->{updated_at}); 44 | my $ref = JSON::decode_json($row->{meta}||'{}'); 45 | my %result = ( 46 | %$ref, 47 | %$row 48 | ); 49 | $result{colors} = encode_json([$result{color}]); 50 | \%result 51 | } 52 | 53 | sub inflate_data_row { 54 | my ($self, $row) = @_; 55 | $row->{datetime} = Time::Piece->from_mysql_datetime($row->{datetime}); 56 | $row->{updated_at} = Time::Piece->from_mysql_timestamp($row->{updated_at}); 57 | my %result = ( 58 | %$row 59 | ); 60 | \%result 61 | } 62 | 63 | sub inflate_complex_row { 64 | my ($self, $row) = @_; 65 | $row->{created_at} = Time::Piece->from_mysql_datetime($row->{created_at}); 66 | $row->{updated_at} = Time::Piece->from_mysql_timestamp($row->{updated_at}); 67 | my $ref = JSON::decode_json($row->{meta}||'{}'); 68 | $ref->{'path-data'} = [ $ref->{'path-data'} ] if ! ref $ref->{'path-data'}; 69 | $ref->{uri} = join ":", @{ $ref->{'path-data'} }; 70 | $ref->{complex} = 1; 71 | $ref->{metricses} = []; 72 | for my $metrics_id ( @{ $ref->{'path-data'} } ) { 73 | my $data = $self->get_by_id($metrics_id); 74 | push @{$ref->{metricses}}, $data if $data; 75 | } 76 | $ref->{colors} = encode_json([ map { $_->{color} } @{$ref->{metricses}} ]); 77 | my %result = ( 78 | %$ref, 79 | %$row 80 | ); 81 | \%result 82 | } 83 | 84 | sub get { 85 | my ($self, $service, $section, $graph) = @_; 86 | my $row = $self->dbh->select_row( 87 | 'SELECT * FROM metrics WHERE service_name = ? AND section_name = ? AND graph_name = ?', 88 | $service, $section, $graph 89 | ); 90 | return unless $row; 91 | $self->inflate_row($row); 92 | } 93 | 94 | sub get_by_id { 95 | my ($self, $id) = @_; 96 | my $row = $self->dbh->select_row( 97 | 'SELECT * FROM metrics WHERE id = ?', 98 | $id 99 | ); 100 | return unless $row; 101 | $self->inflate_row($row); 102 | } 103 | 104 | sub update { 105 | my ($self, $service, $section, $graph, $number, $timestamp ) = @_; 106 | my $dbh = $self->dbh; 107 | $dbh->begin_work; 108 | my $metrics = $self->get($service, $section, $graph); 109 | if ( ! defined $metrics ) { 110 | my @colors = List::Util::shuffle(qw/33 66 99 cc/); 111 | my $color = '#' . join('', splice(@colors,0,3)); 112 | my $meta = encode_json({ color => $color }); 113 | $dbh->query( 114 | 'INSERT INTO metrics (service_name, section_name, graph_name, meta, created_at) 115 | VALUES (?,?,?,?,NOW())', 116 | $service, $section, $graph, $meta 117 | ); 118 | $metrics = $self->get($service, $section, $graph); 119 | } 120 | $dbh->commit; 121 | 122 | my $fixed_timestamp = $timestamp - ($timestamp % $self->round_interval); 123 | $dbh->query( 124 | 'REPLACE data SET metrics_id = ?, datetime = ?, number = ?', 125 | $metrics->{id}, localtime($fixed_timestamp)->mysql_datetime, $number 126 | ); 127 | 128 | 1; 129 | } 130 | 131 | sub update_metrics { 132 | my ($self, $id, $args) = @_; 133 | my @update = map { delete $args->{$_} } qw/service_name section_name graph_name sort/; 134 | my $meta = encode_json($args); 135 | my $dbh = $self->dbh; 136 | $dbh->query( 137 | 'UPDATE metrics SET service_name=?, section_name=?, graph_name=?, sort=?, meta=? WHERE id = ?', 138 | @update, $meta, $id 139 | ); 140 | return 1; 141 | } 142 | 143 | sub delete_metrics { 144 | my ($self, $id) = @_; 145 | my $dbh = $self->dbh; 146 | $dbh->begin_work; 147 | my $rows = 1; 148 | while ( $rows > 1 ) { 149 | $rows = $dbh->query('DELETE FROM data WHERE metrics_id = ? LIMIT 1000',$id); 150 | } 151 | $dbh->query('DELETE FROM metrics WHERE id =?',$id); 152 | $dbh->commit; 153 | 1; 154 | } 155 | 156 | 157 | sub get_data { 158 | my ($self, $id, $from, $to) = @_; 159 | my @id = ref $id ? @$id : ($id); 160 | my $rows = $self->dbh->select_all( 161 | 'SELECT * FROM data WHERE metrics_id IN (?) AND (datetime BETWEEN ? AND ?) ORDER BY datetime ASC', 162 | \@id, localtime($from)->mysql_datetime, localtime($to)->mysql_datetime 163 | ); 164 | my @ret; 165 | for my $row ( @$rows ) { 166 | push @ret, $self->inflate_data_row($row); 167 | } 168 | return \@ret, { 169 | from => Time::Piece->new($from), 170 | to => Time::Piece->new($to), 171 | }; 172 | } 173 | 174 | 175 | sub get_services { 176 | my $self = shift; 177 | my $rows = $self->dbh->select_all( 178 | 'SELECT DISTINCT service_name FROM metrics ORDER BY service_name', 179 | ); 180 | my $complex_rows = $self->dbh->select_all( 181 | 'SELECT DISTINCT service_name FROM complex ORDER BY service_name', 182 | ); 183 | my @names = uniq map { $_->{service_name} } (@$rows,@$complex_rows); 184 | \@names 185 | } 186 | 187 | sub get_sections { 188 | my $self = shift; 189 | my $service_name = shift; 190 | my $rows = $self->dbh->select_all( 191 | 'SELECT DISTINCT section_name FROM metrics WHERE service_name = ? ORDER BY section_name', 192 | $service_name, 193 | ); 194 | my $complex_rows = $self->dbh->select_all( 195 | 'SELECT DISTINCT section_name FROM complex WHERE service_name = ? ORDER BY section_name', 196 | $service_name, 197 | ); 198 | my @names = uniq map { $_->{section_name} } (@$rows,@$complex_rows); 199 | \@names; 200 | } 201 | 202 | 203 | sub get_metricses { 204 | my $self = shift; 205 | my ($service_name, $section_name) = @_; 206 | my $rows = $self->dbh->select_all( 207 | 'SELECT * FROM metrics WHERE service_name = ? AND section_name = ? ORDER BY sort DESC, graph_name', 208 | $service_name, $section_name 209 | ); 210 | my $complex_rows = $self->dbh->select_all( 211 | 'SELECT * FROM complex WHERE service_name = ? AND section_name = ? ORDER BY sort DESC, graph_name', 212 | $service_name, $section_name 213 | ); 214 | my @ret; 215 | for my $row ( @$rows ) { 216 | push @ret, $self->inflate_row($row); 217 | } 218 | for my $row ( @$complex_rows ) { 219 | push @ret, $self->inflate_complex_row($row); 220 | } 221 | @ret = sort { $b->{sort} <=> $a->{sort} } @ret; 222 | \@ret; 223 | } 224 | 225 | sub get_all_metrics_name { 226 | my $self = shift; 227 | $self->dbh->select_all( 228 | 'SELECT id,service_name,section_name,graph_name FROM metrics ORDER BY service_name, section_name, sort DESC, graph_name', 229 | ); 230 | } 231 | 232 | sub get_complex { 233 | my ($self, $service, $section, $graph) = @_; 234 | my $row = $self->dbh->select_row( 235 | 'SELECT * FROM complex WHERE service_name = ? AND section_name = ? AND graph_name = ?', 236 | $service, $section, $graph 237 | ); 238 | return unless $row; 239 | $self->inflate_complex_row($row); 240 | } 241 | 242 | sub get_complex_by_id { 243 | my ($self, $id) = @_; 244 | my $row = $self->dbh->select_row( 245 | 'SELECT * FROM complex WHERE id = ?', 246 | $id 247 | ); 248 | return unless $row; 249 | $self->inflate_complex_row($row); 250 | } 251 | 252 | sub create_complex { 253 | my ($self, $service, $section, $graph, $args) = @_; 254 | my @update = map { delete $args->{$_} } qw/sort/; 255 | my $meta = encode_json($args); 256 | $self->dbh->query( 257 | 'INSERT INTO complex (service_name, section_name, graph_name, sort, meta, created_at) 258 | VALUES (?,?,?,?,?,NOW())', 259 | $service, $section, $graph, @update, $meta 260 | ); 261 | $self->get_complex($service, $section, $graph); 262 | } 263 | 264 | sub update_complex { 265 | my ($self, $id, $args) = @_; 266 | my @update = map { delete $args->{$_} } qw/service_name section_name graph_name sort/; 267 | my $meta = encode_json($args); 268 | $self->dbh->query( 269 | 'UPDATE complex SET service_name=?, section_name=?, graph_name=?, sort=?, meta=? WHERE id=?', 270 | @update, $meta, $id 271 | ); 272 | } 273 | 274 | sub delete_complex { 275 | my ($self, $id) = @_; 276 | $self->dbh->query( 277 | 'DELETE FROM complex WHERE id=?', 278 | $id 279 | ); 280 | } 281 | 282 | 1; 283 | 284 | 285 | -------------------------------------------------------------------------------- /public/css/bootstrap-theme.min.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap v3.0.3 (http://getbootstrap.com) 3 | * Copyright 2013 Twitter, Inc. 4 | * Licensed under http://www.apache.org/licenses/LICENSE-2.0 5 | */ 6 | 7 | .btn-default,.btn-primary,.btn-success,.btn-info,.btn-warning,.btn-danger{text-shadow:0 -1px 0 rgba(0,0,0,0.2);-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,0.15),0 1px 1px rgba(0,0,0,0.075);box-shadow:inset 0 1px 0 rgba(255,255,255,0.15),0 1px 1px rgba(0,0,0,0.075)}.btn-default:active,.btn-primary:active,.btn-success:active,.btn-info:active,.btn-warning:active,.btn-danger:active,.btn-default.active,.btn-primary.active,.btn-success.active,.btn-info.active,.btn-warning.active,.btn-danger.active{-webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,0.125);box-shadow:inset 0 3px 5px rgba(0,0,0,0.125)}.btn:active,.btn.active{background-image:none}.btn-default{text-shadow:0 1px 0 #fff;background-image:-webkit-linear-gradient(top,#fff 0,#e0e0e0 100%);background-image:linear-gradient(to bottom,#fff 0,#e0e0e0 100%);background-repeat:repeat-x;border-color:#dbdbdb;border-color:#ccc;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff',endColorstr='#ffe0e0e0',GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false)}.btn-default:hover,.btn-default:focus{background-color:#e0e0e0;background-position:0 -15px}.btn-default:active,.btn-default.active{background-color:#e0e0e0;border-color:#dbdbdb}.btn-primary{background-image:-webkit-linear-gradient(top,#428bca 0,#2d6ca2 100%);background-image:linear-gradient(to bottom,#428bca 0,#2d6ca2 100%);background-repeat:repeat-x;border-color:#2b669a;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff428bca',endColorstr='#ff2d6ca2',GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false)}.btn-primary:hover,.btn-primary:focus{background-color:#2d6ca2;background-position:0 -15px}.btn-primary:active,.btn-primary.active{background-color:#2d6ca2;border-color:#2b669a}.btn-success{background-image:-webkit-linear-gradient(top,#5cb85c 0,#419641 100%);background-image:linear-gradient(to bottom,#5cb85c 0,#419641 100%);background-repeat:repeat-x;border-color:#3e8f3e;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c',endColorstr='#ff419641',GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false)}.btn-success:hover,.btn-success:focus{background-color:#419641;background-position:0 -15px}.btn-success:active,.btn-success.active{background-color:#419641;border-color:#3e8f3e}.btn-warning{background-image:-webkit-linear-gradient(top,#f0ad4e 0,#eb9316 100%);background-image:linear-gradient(to bottom,#f0ad4e 0,#eb9316 100%);background-repeat:repeat-x;border-color:#e38d13;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e',endColorstr='#ffeb9316',GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false)}.btn-warning:hover,.btn-warning:focus{background-color:#eb9316;background-position:0 -15px}.btn-warning:active,.btn-warning.active{background-color:#eb9316;border-color:#e38d13}.btn-danger{background-image:-webkit-linear-gradient(top,#d9534f 0,#c12e2a 100%);background-image:linear-gradient(to bottom,#d9534f 0,#c12e2a 100%);background-repeat:repeat-x;border-color:#b92c28;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f',endColorstr='#ffc12e2a',GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false)}.btn-danger:hover,.btn-danger:focus{background-color:#c12e2a;background-position:0 -15px}.btn-danger:active,.btn-danger.active{background-color:#c12e2a;border-color:#b92c28}.btn-info{background-image:-webkit-linear-gradient(top,#5bc0de 0,#2aabd2 100%);background-image:linear-gradient(to bottom,#5bc0de 0,#2aabd2 100%);background-repeat:repeat-x;border-color:#28a4c9;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de',endColorstr='#ff2aabd2',GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false)}.btn-info:hover,.btn-info:focus{background-color:#2aabd2;background-position:0 -15px}.btn-info:active,.btn-info.active{background-color:#2aabd2;border-color:#28a4c9}.thumbnail,.img-thumbnail{-webkit-box-shadow:0 1px 2px rgba(0,0,0,0.075);box-shadow:0 1px 2px rgba(0,0,0,0.075)}.dropdown-menu>li>a:hover,.dropdown-menu>li>a:focus{background-color:#e8e8e8;background-image:-webkit-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:linear-gradient(to bottom,#f5f5f5 0,#e8e8e8 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5',endColorstr='#ffe8e8e8',GradientType=0)}.dropdown-menu>.active>a,.dropdown-menu>.active>a:hover,.dropdown-menu>.active>a:focus{background-color:#357ebd;background-image:-webkit-linear-gradient(top,#428bca 0,#357ebd 100%);background-image:linear-gradient(to bottom,#428bca 0,#357ebd 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff428bca',endColorstr='#ff357ebd',GradientType=0)}.navbar-default{background-image:-webkit-linear-gradient(top,#fff 0,#f8f8f8 100%);background-image:linear-gradient(to bottom,#fff 0,#f8f8f8 100%);background-repeat:repeat-x;border-radius:4px;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffffffff',endColorstr='#fff8f8f8',GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false);-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,0.15),0 1px 5px rgba(0,0,0,0.075);box-shadow:inset 0 1px 0 rgba(255,255,255,0.15),0 1px 5px rgba(0,0,0,0.075)}.navbar-default .navbar-nav>.active>a{background-image:-webkit-linear-gradient(top,#ebebeb 0,#f3f3f3 100%);background-image:linear-gradient(to bottom,#ebebeb 0,#f3f3f3 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffebebeb',endColorstr='#fff3f3f3',GradientType=0);-webkit-box-shadow:inset 0 3px 9px rgba(0,0,0,0.075);box-shadow:inset 0 3px 9px rgba(0,0,0,0.075)}.navbar-brand,.navbar-nav>li>a{text-shadow:0 1px 0 rgba(255,255,255,0.25)}.navbar-inverse{background-image:-webkit-linear-gradient(top,#3c3c3c 0,#222 100%);background-image:linear-gradient(to bottom,#3c3c3c 0,#222 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff3c3c3c',endColorstr='#ff222222',GradientType=0);filter:progid:DXImageTransform.Microsoft.gradient(enabled=false)}.navbar-inverse .navbar-nav>.active>a{background-image:-webkit-linear-gradient(top,#222 0,#282828 100%);background-image:linear-gradient(to bottom,#222 0,#282828 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff222222',endColorstr='#ff282828',GradientType=0);-webkit-box-shadow:inset 0 3px 9px rgba(0,0,0,0.25);box-shadow:inset 0 3px 9px rgba(0,0,0,0.25)}.navbar-inverse .navbar-brand,.navbar-inverse .navbar-nav>li>a{text-shadow:0 -1px 0 rgba(0,0,0,0.25)}.navbar-static-top,.navbar-fixed-top,.navbar-fixed-bottom{border-radius:0}.alert{text-shadow:0 1px 0 rgba(255,255,255,0.2);-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,0.25),0 1px 2px rgba(0,0,0,0.05);box-shadow:inset 0 1px 0 rgba(255,255,255,0.25),0 1px 2px rgba(0,0,0,0.05)}.alert-success{background-image:-webkit-linear-gradient(top,#dff0d8 0,#c8e5bc 100%);background-image:linear-gradient(to bottom,#dff0d8 0,#c8e5bc 100%);background-repeat:repeat-x;border-color:#b2dba1;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8',endColorstr='#ffc8e5bc',GradientType=0)}.alert-info{background-image:-webkit-linear-gradient(top,#d9edf7 0,#b9def0 100%);background-image:linear-gradient(to bottom,#d9edf7 0,#b9def0 100%);background-repeat:repeat-x;border-color:#9acfea;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7',endColorstr='#ffb9def0',GradientType=0)}.alert-warning{background-image:-webkit-linear-gradient(top,#fcf8e3 0,#f8efc0 100%);background-image:linear-gradient(to bottom,#fcf8e3 0,#f8efc0 100%);background-repeat:repeat-x;border-color:#f5e79e;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3',endColorstr='#fff8efc0',GradientType=0)}.alert-danger{background-image:-webkit-linear-gradient(top,#f2dede 0,#e7c3c3 100%);background-image:linear-gradient(to bottom,#f2dede 0,#e7c3c3 100%);background-repeat:repeat-x;border-color:#dca7a7;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede',endColorstr='#ffe7c3c3',GradientType=0)}.progress{background-image:-webkit-linear-gradient(top,#ebebeb 0,#f5f5f5 100%);background-image:linear-gradient(to bottom,#ebebeb 0,#f5f5f5 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffebebeb',endColorstr='#fff5f5f5',GradientType=0)}.progress-bar{background-image:-webkit-linear-gradient(top,#428bca 0,#3071a9 100%);background-image:linear-gradient(to bottom,#428bca 0,#3071a9 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff428bca',endColorstr='#ff3071a9',GradientType=0)}.progress-bar-success{background-image:-webkit-linear-gradient(top,#5cb85c 0,#449d44 100%);background-image:linear-gradient(to bottom,#5cb85c 0,#449d44 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5cb85c',endColorstr='#ff449d44',GradientType=0)}.progress-bar-info{background-image:-webkit-linear-gradient(top,#5bc0de 0,#31b0d5 100%);background-image:linear-gradient(to bottom,#5bc0de 0,#31b0d5 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff5bc0de',endColorstr='#ff31b0d5',GradientType=0)}.progress-bar-warning{background-image:-webkit-linear-gradient(top,#f0ad4e 0,#ec971f 100%);background-image:linear-gradient(to bottom,#f0ad4e 0,#ec971f 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff0ad4e',endColorstr='#ffec971f',GradientType=0)}.progress-bar-danger{background-image:-webkit-linear-gradient(top,#d9534f 0,#c9302c 100%);background-image:linear-gradient(to bottom,#d9534f 0,#c9302c 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9534f',endColorstr='#ffc9302c',GradientType=0)}.list-group{border-radius:4px;-webkit-box-shadow:0 1px 2px rgba(0,0,0,0.075);box-shadow:0 1px 2px rgba(0,0,0,0.075)}.list-group-item.active,.list-group-item.active:hover,.list-group-item.active:focus{text-shadow:0 -1px 0 #3071a9;background-image:-webkit-linear-gradient(top,#428bca 0,#3278b3 100%);background-image:linear-gradient(to bottom,#428bca 0,#3278b3 100%);background-repeat:repeat-x;border-color:#3278b3;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff428bca',endColorstr='#ff3278b3',GradientType=0)}.panel{-webkit-box-shadow:0 1px 2px rgba(0,0,0,0.05);box-shadow:0 1px 2px rgba(0,0,0,0.05)}.panel-default>.panel-heading{background-image:-webkit-linear-gradient(top,#f5f5f5 0,#e8e8e8 100%);background-image:linear-gradient(to bottom,#f5f5f5 0,#e8e8e8 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5',endColorstr='#ffe8e8e8',GradientType=0)}.panel-primary>.panel-heading{background-image:-webkit-linear-gradient(top,#428bca 0,#357ebd 100%);background-image:linear-gradient(to bottom,#428bca 0,#357ebd 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff428bca',endColorstr='#ff357ebd',GradientType=0)}.panel-success>.panel-heading{background-image:-webkit-linear-gradient(top,#dff0d8 0,#d0e9c6 100%);background-image:linear-gradient(to bottom,#dff0d8 0,#d0e9c6 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdff0d8',endColorstr='#ffd0e9c6',GradientType=0)}.panel-info>.panel-heading{background-image:-webkit-linear-gradient(top,#d9edf7 0,#c4e3f3 100%);background-image:linear-gradient(to bottom,#d9edf7 0,#c4e3f3 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffd9edf7',endColorstr='#ffc4e3f3',GradientType=0)}.panel-warning>.panel-heading{background-image:-webkit-linear-gradient(top,#fcf8e3 0,#faf2cc 100%);background-image:linear-gradient(to bottom,#fcf8e3 0,#faf2cc 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffcf8e3',endColorstr='#fffaf2cc',GradientType=0)}.panel-danger>.panel-heading{background-image:-webkit-linear-gradient(top,#f2dede 0,#ebcccc 100%);background-image:linear-gradient(to bottom,#f2dede 0,#ebcccc 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff2dede',endColorstr='#ffebcccc',GradientType=0)}.well{background-image:-webkit-linear-gradient(top,#e8e8e8 0,#f5f5f5 100%);background-image:linear-gradient(to bottom,#e8e8e8 0,#f5f5f5 100%);background-repeat:repeat-x;border-color:#dcdcdc;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffe8e8e8',endColorstr='#fff5f5f5',GradientType=0);-webkit-box-shadow:inset 0 1px 3px rgba(0,0,0,0.05),0 1px 0 rgba(255,255,255,0.1);box-shadow:inset 0 1px 3px rgba(0,0,0,0.05),0 1px 0 rgba(255,255,255,0.1)} -------------------------------------------------------------------------------- /public/js/site.js: -------------------------------------------------------------------------------- 1 | var suffixes = ['', 'k', 'M', 'G', 'T','P']; 2 | function round(num, places) { 3 | var shift = Math.pow(10, places); 4 | return Math.round(num * shift)/shift; 5 | }; 6 | function formatDate(date) { 7 | var yyyy = date.getFullYear(); 8 | var mm = ('0' + (date.getMonth() + 1)).slice(-2); 9 | var dd = ('0' + date.getDate()).slice(-2); 10 | var hh = ('0' + date.getHours()).slice(-2); 11 | var ii = ('0' + date.getMinutes()).slice(-2); 12 | var ss = ('0' + date.getSeconds()).slice(-2); 13 | var yyyymmdd = yyyy + '/' + mm + '/' + dd; 14 | var hhii = hh + ':' + ii; 15 | return yyyymmdd + ((hhii == '00:00') ? '' : ' ' + hhii); 16 | } 17 | function formatValue(v) { 18 | if (v < 1000) return v; 19 | var magnitude = Math.floor(String(Math.floor(v)).length / 3); 20 | if (magnitude > suffixes.length - 1) 21 | magnitude = suffixes.length - 1; 22 | return String(round(v / Math.pow(10, magnitude * 3), 2)) + 23 | suffixes[magnitude]; 24 | } 25 | function addFigure(str) { 26 | var num = new String(str).replace(/,/g, ""); 27 | while(num != (num = num.replace(/^(-?\d+)(\d{3})/, "$1,$2"))); 28 | return num; 29 | } 30 | function addFigureVal(str) { 31 | return " "+addFigure(str); 32 | } 33 | 34 | function throttle(callback, wait) { 35 | var timer; 36 | return function() { 37 | if (timer) return; 38 | timer = setTimeout(function() { 39 | timer = null; 40 | callback(); 41 | }, wait); 42 | }; 43 | } 44 | waitForAppear = (function(){ 45 | var jobs = {}; 46 | var $window = $(window); 47 | var elementHasAppeared = function(window_top, window_bottom, $element) { 48 | var element_middle = $element.offset().top + $element.height() / 2; 49 | return window_top < element_middle && element_middle < window_bottom; 50 | }; 51 | $window.scroll(throttle(function() { 52 | var window_top = $window.scrollTop(); 53 | var window_bottom = window_top + $window.height(); 54 | $.each(jobs, function(key, pair) { 55 | var $element = pair[0]; 56 | var callback = pair[1]; 57 | if (elementHasAppeared(window_top, window_bottom, $element)) { 58 | callback(); 59 | delete jobs[key]; 60 | } 61 | }); 62 | }, 200)); 63 | return function(key, $element, callback) { 64 | var window_top = $window.scrollTop(); 65 | var window_bottom = window_top + $window.height(); 66 | if (elementHasAppeared(window_top, window_bottom, $element)) { 67 | setTimeout(callback, 0); 68 | return; 69 | } 70 | jobs[key] = [$element, callback]; 71 | }; 72 | })(); 73 | function loadGraphsLater () { 74 | var element = this; 75 | var $element = $(this); 76 | waitForAppear($element.attr('data-csv'), $element, function() { 77 | loadGraphs.apply(element); 78 | }); 79 | } 80 | function loadGraphs () { 81 | var gdiv = $(this); 82 | var limit = 8; 83 | var tooltip = $('#tooltip'); 84 | if (tooltip.size() == 0) { 85 | tooltip = $('


'); 86 | $(document.body).append(tooltip); 87 | } 88 | 89 | $('#'+'label-'+gdiv.data('index')).removeClass('dygraph-closest-legend'); 90 | $('#'+'label-'+gdiv.data('index')).removeClass('dygraph-highlighted-legend'); 91 | if ( gdiv.data('colors').length > limit ) { 92 | $('#'+'label-'+gdiv.data('index')).addClass('dygraph-closest-legend'); 93 | } else if ( gdiv.data('colors').length > 1 ) { 94 | $('#'+'label-'+gdiv.data('index')).addClass('dygraph-highlighted-legend'); 95 | } 96 | $('#onmouse-'+gdiv.data('index')).hide(); 97 | var g = new Dygraph( 98 | gdiv.context, 99 | gdiv.data('csv'), 100 | { 101 | includeZero: true, 102 | dateWindow: [ Date.parse(gdiv.data('datewindow')[0]),Date.parse(gdiv.data('datewindow')[1]) ], 103 | colors: gdiv.data('colors'), 104 | stackedGraph: gdiv.data('stack') ? true : false, 105 | drawPoints: false, 106 | strokeWidth: 1, 107 | strokeBorderWidth: gdiv.data('colors').length > limit ? 1 : null, 108 | highlightCircleSize: 3, 109 | highlightSeriesBackgroundAlpha: gdiv.data('colors').length > limit ? 0.5 : 1, 110 | highlightSeriesOpts: gdiv.data('colors').length > limit ? { 111 | strokeWidth: 2, 112 | strokeBorderWidth: 1, 113 | highlightCircleSize: 5, 114 | } : { 115 | highlightCircleSize: gdiv.data('colors').length > 1 ? 5 : 3, 116 | }, 117 | labelsKMB: true, 118 | labelsDiv: 'onmouse-'+gdiv.data('index'), 119 | labelsSeparateLines: gdiv.data('colors').length > limit ? false : true, 120 | legend: gdiv.data('colors').length > limit ? 'onmouseover' : 'always', 121 | axes: { 122 | x: { 123 | pixelsPerLabel: 28 124 | }, 125 | y: { 126 | valueFormatter: addFigureVal 127 | } 128 | }, 129 | axisLabelFontSize: 12, 130 | highlightCallback: function(e, x, pts, row, name){ 131 | var total = 0; 132 | $('#onmouse-'+gdiv.data('index')).show(); 133 | $('#label-'+gdiv.data('index')).hide(); 134 | $.each(pts,function(idx,val){ 135 | total += val.yval; 136 | }); 137 | if ( gdiv.data('stack') ) { 138 | $('#total-'+gdiv.data('index')).html('
TOTAL:'+addFigureVal(total)); 139 | $('#tooltip .total').text("TOTAL: " + addFigureVal(total)); 140 | } 141 | $('#tooltip').show(); 142 | $('#tooltip').css({left:e.pageX + 10, top:e.pageY + 10}); 143 | $('#tooltip .xval').text(formatDate(new Date(x)) + ':'); 144 | for (var i in pts) { 145 | if (pts[i].name == name) { 146 | $('#tooltip .yval').text(name + ': ' + addFigureVal(pts[i].yval)); 147 | } 148 | } 149 | }, 150 | unhighlightCallback: function(e) { 151 | $('#onmouse-'+gdiv.data('index')).hide(); 152 | $('#label-'+gdiv.data('index')).show(); 153 | $('#total-'+gdiv.data('index')).html(''); 154 | $('#tooltip').hide(); 155 | } 156 | } 157 | ); 158 | }; 159 | function setHxrpost() { 160 | var myform = this; 161 | $(myform).first().prepend('
System Error!
'); 162 | $(myform).submit(function(){ 163 | $(myform).find('.alert-error').hide(); 164 | $(myform).find('.validator_message').addClass('hide'); 165 | $(myform).find('div.form-group').removeClass('has-error'); 166 | $.ajax({ 167 | type: 'POST', 168 | url: myform.action, 169 | data: $(myform).serialize(), 170 | success: function(data) { 171 | $(myform).find('.alert-error').hide(); 172 | if ( data.error == 0 ) { 173 | location.href = data.location; 174 | } 175 | else { 176 | $.each(data.messages, function (param,message) { 177 | var name = param; 178 | if ( param == 'path-data' ) { 179 | name = 'path-add'; 180 | } 181 | var parent = $(myform).find('[name="'+param+'"]').parents('div.form-group').first(); 182 | parent.find('.validator_message').text(message).removeClass('hide'); 183 | parent.addClass('has-error'); 184 | }); 185 | } 186 | }, 187 | error: function() { 188 | $(myform).find('.alert-error').show(); 189 | } 190 | }); 191 | return false; 192 | }); 193 | }; 194 | 195 | function setHxrConfirmBtn() { 196 | var mybtn = this; 197 | var modal = $(''); 207 | modal.find('h3').text($(mybtn).text()); 208 | modal.find('input[type=submit]').attr('value',$(mybtn).text()); 209 | modal.find('.modal-body > p').text( $(mybtn).data('confirm') ); 210 | modal.find('form').submit(function(){ 211 | $.ajax({ 212 | type: 'POST', 213 | url: $(mybtn).data('uri'), 214 | data: modal.find('form').serialize(), 215 | success: function(data) { 216 | modal.find('.alert-error').hide(); 217 | if ( data.error == 0 ) { 218 | location.href = data.location; 219 | } 220 | }, 221 | error: function() { 222 | modal.find('.alert-error').show(); 223 | } 224 | }); 225 | return false; 226 | }); 227 | $(mybtn).click(function(){ 228 | modal.modal({ 229 | show: true, 230 | }); 231 | }); 232 | }; 233 | 234 | function addNewRow() { 235 | var metrics = $('select[name="path-add"]#select_metrics'); 236 | var option = metrics.find('option:selected'); 237 | var label = '/'+option.data('parent')+'/'+option.text().replace(/(^\s+)|(\s+$)/g, ""); 238 | var tr = $(''); 239 | tr.append(''); 240 | tr.append(''+label+''); 241 | tr.append(''); 242 | tr.appendTo($('table#data-tbl')); 243 | 244 | $('#data-tbl').find('tr:last').addClass('can-table-order'); 245 | $('#data-tbl').find('span.table-order-up:last').click(tableOrderUp); 246 | $('#data-tbl').find('span.table-order-down:last').click(tableOrderDown); 247 | $('#data-tbl').find('span.table-order-remove:last').click(tableOrderRemove); 248 | 249 | var myform = $(this).parents('form').first(); 250 | setTimeout(function(){tablePreview(myform)},10); 251 | 252 | return false; 253 | }; 254 | 255 | function tableOrderUp() { 256 | var btn = this; 257 | var mytr = $(this).parents('tr.can-table-order').first(); 258 | if ( mytr ) { 259 | var prevtr = mytr.prev('tr.can-table-order'); 260 | mytr.insertBefore(prevtr); 261 | } 262 | var myform = $(this).parents('form').first(); 263 | setTimeout(function(){tablePreview(myform)},10); 264 | return false; 265 | } 266 | 267 | function tableOrderDown() { 268 | var btn = this; 269 | var mytr = $(this).parents('tr.can-table-order').first(); 270 | if ( mytr ) { 271 | var nexttr = mytr.next('tr.can-table-order'); 272 | mytr.insertAfter(nexttr); 273 | } 274 | var myform = $(this).parents('form').first(); 275 | setTimeout(function(){tablePreview(myform)},10); 276 | return false; 277 | }; 278 | 279 | function tableOrderRemove() { 280 | var btn = this; 281 | var mytr = $(this).parents('tr.can-table-order').first(); 282 | var myform = $(this).parents('form').first(); 283 | setTimeout(function(){tablePreview(myform)},10); 284 | mytr.detach(); 285 | }; 286 | 287 | function tablePreview(myform) { 288 | var num = myform.find('input[name="path-data"]').length; 289 | var uri = $('#complex-preview').data('base'); 290 | var data = new Array(); 291 | myform.find('input[name="path-data"]').each(function(){ data.push($(this).val()) }); 292 | uri += data.join(':'); 293 | uri += '?stack=' + myform.find('select[name="stack"]').val(); 294 | console.log(uri); 295 | $('#complex-preview').attr('src',uri); 296 | }; 297 | 298 | function setTablePreview() { 299 | var myform = $(this); 300 | $('#data-tbl').find('span.table-order-up').click(tableOrderUp); 301 | $('#data-tbl').find('span.table-order-down').click(tableOrderDown); 302 | $('#data-tbl').find('span.table-order-remove').click(tableOrderRemove); 303 | tablePreview(myform); 304 | myform.find('select[name="stack"]').change( 305 | function() { 306 | setTimeout(function(){ tablePreview(myform) }, 10) 307 | } 308 | ); 309 | }; 310 | 311 | $(function() { 312 | $('select#select_service').change(); 313 | }); 314 | 315 | $(document).on('change', 'select#select_service', function() { 316 | var name0 = $('select#select_service').val(); 317 | $('select#select_section option').remove(); 318 | var options = $('select#select_section_original option[data-parent="' + name0 + '"]').clone(); 319 | $('select#select_section').append(options); 320 | $('select#select_section').change(); 321 | }); 322 | 323 | $(document).on('change', 'select#select_section', function() { 324 | var name0 = $('select#select_service').val(); 325 | var name1 = $('select#select_section').val(); 326 | $('select#select_metrics option').remove(); 327 | var options = $('select#select_metrics_original option[data-parent="' + name0 + '/' + name1 + '"]').clone(); 328 | $('select#select_metrics').append(options); 329 | }); 330 | 331 | -------------------------------------------------------------------------------- /public/js/bootstrap.min.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap v3.0.3 (http://getbootstrap.com) 3 | * Copyright 2013 Twitter, Inc. 4 | * Licensed under http://www.apache.org/licenses/LICENSE-2.0 5 | */ 6 | 7 | if("undefined"==typeof jQuery)throw new Error("Bootstrap requires jQuery");+function(a){"use strict";function b(){var a=document.createElement("bootstrap"),b={WebkitTransition:"webkitTransitionEnd",MozTransition:"transitionend",OTransition:"oTransitionEnd otransitionend",transition:"transitionend"};for(var c in b)if(void 0!==a.style[c])return{end:b[c]}}a.fn.emulateTransitionEnd=function(b){var c=!1,d=this;a(this).one(a.support.transition.end,function(){c=!0});var e=function(){c||a(d).trigger(a.support.transition.end)};return setTimeout(e,b),this},a(function(){a.support.transition=b()})}(jQuery),+function(a){"use strict";var b='[data-dismiss="alert"]',c=function(c){a(c).on("click",b,this.close)};c.prototype.close=function(b){function c(){f.trigger("closed.bs.alert").remove()}var d=a(this),e=d.attr("data-target");e||(e=d.attr("href"),e=e&&e.replace(/.*(?=#[^\s]*$)/,""));var f=a(e);b&&b.preventDefault(),f.length||(f=d.hasClass("alert")?d:d.parent()),f.trigger(b=a.Event("close.bs.alert")),b.isDefaultPrevented()||(f.removeClass("in"),a.support.transition&&f.hasClass("fade")?f.one(a.support.transition.end,c).emulateTransitionEnd(150):c())};var d=a.fn.alert;a.fn.alert=function(b){return this.each(function(){var d=a(this),e=d.data("bs.alert");e||d.data("bs.alert",e=new c(this)),"string"==typeof b&&e[b].call(d)})},a.fn.alert.Constructor=c,a.fn.alert.noConflict=function(){return a.fn.alert=d,this},a(document).on("click.bs.alert.data-api",b,c.prototype.close)}(jQuery),+function(a){"use strict";var b=function(c,d){this.$element=a(c),this.options=a.extend({},b.DEFAULTS,d)};b.DEFAULTS={loadingText:"loading..."},b.prototype.setState=function(a){var b="disabled",c=this.$element,d=c.is("input")?"val":"html",e=c.data();a+="Text",e.resetText||c.data("resetText",c[d]()),c[d](e[a]||this.options[a]),setTimeout(function(){"loadingText"==a?c.addClass(b).attr(b,b):c.removeClass(b).removeAttr(b)},0)},b.prototype.toggle=function(){var a=this.$element.closest('[data-toggle="buttons"]'),b=!0;if(a.length){var c=this.$element.find("input");"radio"===c.prop("type")&&(c.prop("checked")&&this.$element.hasClass("active")?b=!1:a.find(".active").removeClass("active")),b&&c.prop("checked",!this.$element.hasClass("active")).trigger("change")}b&&this.$element.toggleClass("active")};var c=a.fn.button;a.fn.button=function(c){return this.each(function(){var d=a(this),e=d.data("bs.button"),f="object"==typeof c&&c;e||d.data("bs.button",e=new b(this,f)),"toggle"==c?e.toggle():c&&e.setState(c)})},a.fn.button.Constructor=b,a.fn.button.noConflict=function(){return a.fn.button=c,this},a(document).on("click.bs.button.data-api","[data-toggle^=button]",function(b){var c=a(b.target);c.hasClass("btn")||(c=c.closest(".btn")),c.button("toggle"),b.preventDefault()})}(jQuery),+function(a){"use strict";var b=function(b,c){this.$element=a(b),this.$indicators=this.$element.find(".carousel-indicators"),this.options=c,this.paused=this.sliding=this.interval=this.$active=this.$items=null,"hover"==this.options.pause&&this.$element.on("mouseenter",a.proxy(this.pause,this)).on("mouseleave",a.proxy(this.cycle,this))};b.DEFAULTS={interval:5e3,pause:"hover",wrap:!0},b.prototype.cycle=function(b){return b||(this.paused=!1),this.interval&&clearInterval(this.interval),this.options.interval&&!this.paused&&(this.interval=setInterval(a.proxy(this.next,this),this.options.interval)),this},b.prototype.getActiveIndex=function(){return this.$active=this.$element.find(".item.active"),this.$items=this.$active.parent().children(),this.$items.index(this.$active)},b.prototype.to=function(b){var c=this,d=this.getActiveIndex();return b>this.$items.length-1||0>b?void 0:this.sliding?this.$element.one("slid.bs.carousel",function(){c.to(b)}):d==b?this.pause().cycle():this.slide(b>d?"next":"prev",a(this.$items[b]))},b.prototype.pause=function(b){return b||(this.paused=!0),this.$element.find(".next, .prev").length&&a.support.transition.end&&(this.$element.trigger(a.support.transition.end),this.cycle(!0)),this.interval=clearInterval(this.interval),this},b.prototype.next=function(){return this.sliding?void 0:this.slide("next")},b.prototype.prev=function(){return this.sliding?void 0:this.slide("prev")},b.prototype.slide=function(b,c){var d=this.$element.find(".item.active"),e=c||d[b](),f=this.interval,g="next"==b?"left":"right",h="next"==b?"first":"last",i=this;if(!e.length){if(!this.options.wrap)return;e=this.$element.find(".item")[h]()}this.sliding=!0,f&&this.pause();var j=a.Event("slide.bs.carousel",{relatedTarget:e[0],direction:g});if(!e.hasClass("active")){if(this.$indicators.length&&(this.$indicators.find(".active").removeClass("active"),this.$element.one("slid.bs.carousel",function(){var b=a(i.$indicators.children()[i.getActiveIndex()]);b&&b.addClass("active")})),a.support.transition&&this.$element.hasClass("slide")){if(this.$element.trigger(j),j.isDefaultPrevented())return;e.addClass(b),e[0].offsetWidth,d.addClass(g),e.addClass(g),d.one(a.support.transition.end,function(){e.removeClass([b,g].join(" ")).addClass("active"),d.removeClass(["active",g].join(" ")),i.sliding=!1,setTimeout(function(){i.$element.trigger("slid.bs.carousel")},0)}).emulateTransitionEnd(600)}else{if(this.$element.trigger(j),j.isDefaultPrevented())return;d.removeClass("active"),e.addClass("active"),this.sliding=!1,this.$element.trigger("slid.bs.carousel")}return f&&this.cycle(),this}};var c=a.fn.carousel;a.fn.carousel=function(c){return this.each(function(){var d=a(this),e=d.data("bs.carousel"),f=a.extend({},b.DEFAULTS,d.data(),"object"==typeof c&&c),g="string"==typeof c?c:f.slide;e||d.data("bs.carousel",e=new b(this,f)),"number"==typeof c?e.to(c):g?e[g]():f.interval&&e.pause().cycle()})},a.fn.carousel.Constructor=b,a.fn.carousel.noConflict=function(){return a.fn.carousel=c,this},a(document).on("click.bs.carousel.data-api","[data-slide], [data-slide-to]",function(b){var c,d=a(this),e=a(d.attr("data-target")||(c=d.attr("href"))&&c.replace(/.*(?=#[^\s]+$)/,"")),f=a.extend({},e.data(),d.data()),g=d.attr("data-slide-to");g&&(f.interval=!1),e.carousel(f),(g=d.attr("data-slide-to"))&&e.data("bs.carousel").to(g),b.preventDefault()}),a(window).on("load",function(){a('[data-ride="carousel"]').each(function(){var b=a(this);b.carousel(b.data())})})}(jQuery),+function(a){"use strict";var b=function(c,d){this.$element=a(c),this.options=a.extend({},b.DEFAULTS,d),this.transitioning=null,this.options.parent&&(this.$parent=a(this.options.parent)),this.options.toggle&&this.toggle()};b.DEFAULTS={toggle:!0},b.prototype.dimension=function(){var a=this.$element.hasClass("width");return a?"width":"height"},b.prototype.show=function(){if(!this.transitioning&&!this.$element.hasClass("in")){var b=a.Event("show.bs.collapse");if(this.$element.trigger(b),!b.isDefaultPrevented()){var c=this.$parent&&this.$parent.find("> .panel > .in");if(c&&c.length){var d=c.data("bs.collapse");if(d&&d.transitioning)return;c.collapse("hide"),d||c.data("bs.collapse",null)}var e=this.dimension();this.$element.removeClass("collapse").addClass("collapsing")[e](0),this.transitioning=1;var f=function(){this.$element.removeClass("collapsing").addClass("in")[e]("auto"),this.transitioning=0,this.$element.trigger("shown.bs.collapse")};if(!a.support.transition)return f.call(this);var g=a.camelCase(["scroll",e].join("-"));this.$element.one(a.support.transition.end,a.proxy(f,this)).emulateTransitionEnd(350)[e](this.$element[0][g])}}},b.prototype.hide=function(){if(!this.transitioning&&this.$element.hasClass("in")){var b=a.Event("hide.bs.collapse");if(this.$element.trigger(b),!b.isDefaultPrevented()){var c=this.dimension();this.$element[c](this.$element[c]())[0].offsetHeight,this.$element.addClass("collapsing").removeClass("collapse").removeClass("in"),this.transitioning=1;var d=function(){this.transitioning=0,this.$element.trigger("hidden.bs.collapse").removeClass("collapsing").addClass("collapse")};return a.support.transition?(this.$element[c](0).one(a.support.transition.end,a.proxy(d,this)).emulateTransitionEnd(350),void 0):d.call(this)}}},b.prototype.toggle=function(){this[this.$element.hasClass("in")?"hide":"show"]()};var c=a.fn.collapse;a.fn.collapse=function(c){return this.each(function(){var d=a(this),e=d.data("bs.collapse"),f=a.extend({},b.DEFAULTS,d.data(),"object"==typeof c&&c);e||d.data("bs.collapse",e=new b(this,f)),"string"==typeof c&&e[c]()})},a.fn.collapse.Constructor=b,a.fn.collapse.noConflict=function(){return a.fn.collapse=c,this},a(document).on("click.bs.collapse.data-api","[data-toggle=collapse]",function(b){var c,d=a(this),e=d.attr("data-target")||b.preventDefault()||(c=d.attr("href"))&&c.replace(/.*(?=#[^\s]+$)/,""),f=a(e),g=f.data("bs.collapse"),h=g?"toggle":d.data(),i=d.attr("data-parent"),j=i&&a(i);g&&g.transitioning||(j&&j.find('[data-toggle=collapse][data-parent="'+i+'"]').not(d).addClass("collapsed"),d[f.hasClass("in")?"addClass":"removeClass"]("collapsed")),f.collapse(h)})}(jQuery),+function(a){"use strict";function b(){a(d).remove(),a(e).each(function(b){var d=c(a(this));d.hasClass("open")&&(d.trigger(b=a.Event("hide.bs.dropdown")),b.isDefaultPrevented()||d.removeClass("open").trigger("hidden.bs.dropdown"))})}function c(b){var c=b.attr("data-target");c||(c=b.attr("href"),c=c&&/#/.test(c)&&c.replace(/.*(?=#[^\s]*$)/,""));var d=c&&a(c);return d&&d.length?d:b.parent()}var d=".dropdown-backdrop",e="[data-toggle=dropdown]",f=function(b){a(b).on("click.bs.dropdown",this.toggle)};f.prototype.toggle=function(d){var e=a(this);if(!e.is(".disabled, :disabled")){var f=c(e),g=f.hasClass("open");if(b(),!g){if("ontouchstart"in document.documentElement&&!f.closest(".navbar-nav").length&&a(''}),b.prototype=a.extend({},a.fn.tooltip.Constructor.prototype),b.prototype.constructor=b,b.prototype.getDefaults=function(){return b.DEFAULTS},b.prototype.setContent=function(){var a=this.tip(),b=this.getTitle(),c=this.getContent();a.find(".popover-title")[this.options.html?"html":"text"](b),a.find(".popover-content")[this.options.html?"html":"text"](c),a.removeClass("fade top bottom left right in"),a.find(".popover-title").html()||a.find(".popover-title").hide()},b.prototype.hasContent=function(){return this.getTitle()||this.getContent()},b.prototype.getContent=function(){var a=this.$element,b=this.options;return a.attr("data-content")||("function"==typeof b.content?b.content.call(a[0]):b.content)},b.prototype.arrow=function(){return this.$arrow=this.$arrow||this.tip().find(".arrow")},b.prototype.tip=function(){return this.$tip||(this.$tip=a(this.options.template)),this.$tip};var c=a.fn.popover;a.fn.popover=function(c){return this.each(function(){var d=a(this),e=d.data("bs.popover"),f="object"==typeof c&&c;e||d.data("bs.popover",e=new b(this,f)),"string"==typeof c&&e[c]()})},a.fn.popover.Constructor=b,a.fn.popover.noConflict=function(){return a.fn.popover=c,this}}(jQuery),+function(a){"use strict";function b(c,d){var e,f=a.proxy(this.process,this);this.$element=a(c).is("body")?a(window):a(c),this.$body=a("body"),this.$scrollElement=this.$element.on("scroll.bs.scroll-spy.data-api",f),this.options=a.extend({},b.DEFAULTS,d),this.selector=(this.options.target||(e=a(c).attr("href"))&&e.replace(/.*(?=#[^\s]+$)/,"")||"")+" .nav li > a",this.offsets=a([]),this.targets=a([]),this.activeTarget=null,this.refresh(),this.process()}b.DEFAULTS={offset:10},b.prototype.refresh=function(){var b=this.$element[0]==window?"offset":"position";this.offsets=a([]),this.targets=a([]);var c=this;this.$body.find(this.selector).map(function(){var d=a(this),e=d.data("target")||d.attr("href"),f=/^#\w/.test(e)&&a(e);return f&&f.length&&[[f[b]().top+(!a.isWindow(c.$scrollElement.get(0))&&c.$scrollElement.scrollTop()),e]]||null}).sort(function(a,b){return a[0]-b[0]}).each(function(){c.offsets.push(this[0]),c.targets.push(this[1])})},b.prototype.process=function(){var a,b=this.$scrollElement.scrollTop()+this.options.offset,c=this.$scrollElement[0].scrollHeight||this.$body[0].scrollHeight,d=c-this.$scrollElement.height(),e=this.offsets,f=this.targets,g=this.activeTarget;if(b>=d)return g!=(a=f.last()[0])&&this.activate(a);for(a=e.length;a--;)g!=f[a]&&b>=e[a]&&(!e[a+1]||b<=e[a+1])&&this.activate(f[a])},b.prototype.activate=function(b){this.activeTarget=b,a(this.selector).parents(".active").removeClass("active");var c=this.selector+'[data-target="'+b+'"],'+this.selector+'[href="'+b+'"]',d=a(c).parents("li").addClass("active");d.parent(".dropdown-menu").length&&(d=d.closest("li.dropdown").addClass("active")),d.trigger("activate.bs.scrollspy")};var c=a.fn.scrollspy;a.fn.scrollspy=function(c){return this.each(function(){var d=a(this),e=d.data("bs.scrollspy"),f="object"==typeof c&&c;e||d.data("bs.scrollspy",e=new b(this,f)),"string"==typeof c&&e[c]()})},a.fn.scrollspy.Constructor=b,a.fn.scrollspy.noConflict=function(){return a.fn.scrollspy=c,this},a(window).on("load",function(){a('[data-spy="scroll"]').each(function(){var b=a(this);b.scrollspy(b.data())})})}(jQuery),+function(a){"use strict";var b=function(b){this.element=a(b)};b.prototype.show=function(){var b=this.element,c=b.closest("ul:not(.dropdown-menu)"),d=b.data("target");if(d||(d=b.attr("href"),d=d&&d.replace(/.*(?=#[^\s]*$)/,"")),!b.parent("li").hasClass("active")){var e=c.find(".active:last a")[0],f=a.Event("show.bs.tab",{relatedTarget:e});if(b.trigger(f),!f.isDefaultPrevented()){var g=a(d);this.activate(b.parent("li"),c),this.activate(g,g.parent(),function(){b.trigger({type:"shown.bs.tab",relatedTarget:e})})}}},b.prototype.activate=function(b,c,d){function e(){f.removeClass("active").find("> .dropdown-menu > .active").removeClass("active"),b.addClass("active"),g?(b[0].offsetWidth,b.addClass("in")):b.removeClass("fade"),b.parent(".dropdown-menu")&&b.closest("li.dropdown").addClass("active"),d&&d()}var f=c.find("> .active"),g=d&&a.support.transition&&f.hasClass("fade");g?f.one(a.support.transition.end,e).emulateTransitionEnd(150):e(),f.removeClass("in")};var c=a.fn.tab;a.fn.tab=function(c){return this.each(function(){var d=a(this),e=d.data("bs.tab");e||d.data("bs.tab",e=new b(this)),"string"==typeof c&&e[c]()})},a.fn.tab.Constructor=b,a.fn.tab.noConflict=function(){return a.fn.tab=c,this},a(document).on("click.bs.tab.data-api",'[data-toggle="tab"], [data-toggle="pill"]',function(b){b.preventDefault(),a(this).tab("show")})}(jQuery),+function(a){"use strict";var b=function(c,d){this.options=a.extend({},b.DEFAULTS,d),this.$window=a(window).on("scroll.bs.affix.data-api",a.proxy(this.checkPosition,this)).on("click.bs.affix.data-api",a.proxy(this.checkPositionWithEventLoop,this)),this.$element=a(c),this.affixed=this.unpin=null,this.checkPosition()};b.RESET="affix affix-top affix-bottom",b.DEFAULTS={offset:0},b.prototype.checkPositionWithEventLoop=function(){setTimeout(a.proxy(this.checkPosition,this),1)},b.prototype.checkPosition=function(){if(this.$element.is(":visible")){var c=a(document).height(),d=this.$window.scrollTop(),e=this.$element.offset(),f=this.options.offset,g=f.top,h=f.bottom;"object"!=typeof f&&(h=g=f),"function"==typeof g&&(g=f.top()),"function"==typeof h&&(h=f.bottom());var i=null!=this.unpin&&d+this.unpin<=e.top?!1:null!=h&&e.top+this.$element.height()>=c-h?"bottom":null!=g&&g>=d?"top":!1;this.affixed!==i&&(this.unpin&&this.$element.css("top",""),this.affixed=i,this.unpin="bottom"==i?e.top-d:null,this.$element.removeClass(b.RESET).addClass("affix"+(i?"-"+i:"")),"bottom"==i&&this.$element.offset({top:document.body.offsetHeight-h-this.$element.height()}))}};var c=a.fn.affix;a.fn.affix=function(c){return this.each(function(){var d=a(this),e=d.data("bs.affix"),f="object"==typeof c&&c;e||d.data("bs.affix",e=new b(this,f)),"string"==typeof c&&e[c]()})},a.fn.affix.Constructor=b,a.fn.affix.noConflict=function(){return a.fn.affix=c,this},a(window).on("load",function(){a('[data-spy="affix"]').each(function(){var b=a(this),c=b.data();c.offset=c.offset||{},c.offsetBottom&&(c.offset.bottom=c.offsetBottom),c.offsetTop&&(c.offset.top=c.offsetTop),b.affix(c)})})}(jQuery); -------------------------------------------------------------------------------- /lib/HRForecast/Web.pm: -------------------------------------------------------------------------------- 1 | package HRForecast::Web; 2 | 3 | use strict; 4 | use warnings; 5 | use utf8; 6 | use Kossy; 7 | use HTTP::Date; 8 | use Time::Piece; 9 | use HRForecast::Data; 10 | use HRForecast::Calculator; 11 | use Log::Minimal; 12 | use JSON qw//; 13 | 14 | my $JSON = JSON->new()->ascii(1); 15 | sub encode_json { 16 | $JSON->encode(shift); 17 | } 18 | 19 | sub data { 20 | my $self = shift; 21 | $self->{__data} ||= HRForecast::Data->new(); 22 | $self->{__data}; 23 | } 24 | 25 | sub calc_term { 26 | my $self = shift; 27 | my %args = @_; 28 | 29 | my $term = $args{t}; 30 | my $from = $args{from}; 31 | my $to = $args{to}; 32 | my $offset = $args{offset}; 33 | my $period = $args{period}; 34 | 35 | if ( $term eq 'w' ) { 36 | $from = time - 86400 * 10; 37 | $to = time; 38 | } 39 | elsif ( $term eq 'm' ) { 40 | $from = time - 86400 * 40; 41 | $to = time; 42 | } 43 | elsif ( $term eq 'y' ) { 44 | $from = time - 86400 * 400; 45 | $to = time; 46 | } 47 | elsif ( $term eq 'range' ) { 48 | $to = time - $offset; 49 | $from = $to - $period; 50 | } 51 | else { 52 | $from = HTTP::Date::str2time($from); 53 | $to = HTTP::Date::str2time($to); 54 | } 55 | $from = localtime($from - ($from % $self->data->round_interval)); 56 | $to = localtime($to - ($to % $self->data->round_interval)); 57 | return ($from,$to); 58 | } 59 | 60 | filter 'sidebar' => sub { 61 | my $app = shift; 62 | sub { 63 | my ( $self, $c ) = @_; 64 | my $services = $self->data->get_services(); 65 | my @services; 66 | for my $service ( @$services ) { 67 | my $sections = $self->data->get_sections($service); 68 | my @sections; 69 | for my $section ( @$sections ) { 70 | push @sections, { 71 | active => 72 | $c->args->{service_name} && $c->args->{service_name} eq $service && 73 | $c->args->{section_name} && $c->args->{section_name} eq $section ? 1 : 0, 74 | name => $section 75 | }; 76 | } 77 | my $dot_escaped = $service; 78 | $dot_escaped =~ s/\./__2E__/g; 79 | push @services , { 80 | name => $service, 81 | collapse => $c->req->cookies->{'sidebar_collapse_' . $dot_escaped}, 82 | sections => \@sections, 83 | }; 84 | } 85 | $c->stash->{services} = \@services; 86 | $app->($self,$c); 87 | } 88 | }; 89 | 90 | 91 | filter 'get_metrics' => sub { 92 | my $app = shift; 93 | sub { 94 | my ($self, $c) = @_; 95 | my $row = $self->data->get( 96 | $c->args->{service_name}, $c->args->{section_name}, $c->args->{graph_name}, 97 | ); 98 | $c->halt(404) unless $row; 99 | $c->stash->{metrics} = $row; 100 | $app->($self,$c); 101 | } 102 | }; 103 | 104 | filter 'get_complex' => sub { 105 | my $app = shift; 106 | sub { 107 | my ($self, $c) = @_; 108 | my $row = $self->data->get_complex( 109 | $c->args->{service_name}, $c->args->{section_name}, $c->args->{graph_name}, 110 | ); 111 | $c->halt(404) unless $row; 112 | $c->stash->{metrics} = $row; 113 | $app->($self,$c); 114 | } 115 | }; 116 | 117 | filter 'unset_frame_option' => sub { 118 | my $app = shift; 119 | sub { 120 | my ($self, $c) = @_; 121 | $c->res->headers->remove_header('X-Frame-Options'); 122 | $app->($self,$c); 123 | } 124 | }; 125 | 126 | filter 'display_table' => sub { 127 | my $app = shift; 128 | sub { 129 | my ($self, $c) = @_; 130 | $c->stash->{display_table} = 1; 131 | $app->($self,$c); 132 | } 133 | }; 134 | 135 | get '/' => [qw/sidebar/] => sub { 136 | my ( $self, $c ) = @_; 137 | $c->render('index.tx', {}); 138 | }; 139 | 140 | get '/json' => [qw/sidebar/] => sub { 141 | my ( $self, $c ) = @_; 142 | $c->render_json({ 143 | error => 0, 144 | services => $c->stash->{services}, 145 | }); 146 | }; 147 | 148 | get '/docs' => [qw/sidebar/] => sub { 149 | my ( $self, $c ) = @_; 150 | $c->render('docs.tx',{calculations => HRForecast::Calculator::CALCULATIONS}); 151 | }; 152 | 153 | my $metrics_validator = [ 154 | 't' => { 155 | default => 'm', 156 | rule => [ 157 | [['CHOICE',qw/w m y c range/],'invalid browse term'], 158 | ], 159 | }, 160 | 'from' => { 161 | default => sub { localtime(time-86400*35)->strftime('%Y/%m/%d %T') }, 162 | rule => [ 163 | [sub{ HTTP::Date::str2time($_[1]) }, 'invalid From datetime'], 164 | ], 165 | }, 166 | 'period' => { 167 | default => 0, 168 | rule => [ 169 | ['UINT', 'invalid interval'], 170 | ], 171 | }, 172 | 'offset' => { 173 | default => 0, 174 | rule => [ 175 | ['UINT', 'invalid offset'], 176 | ], 177 | }, 178 | 'to' => { 179 | default => sub { localtime()->strftime('%Y/%m/%d %T') }, 180 | rule => [ 181 | [sub{ HTTP::Date::str2time($_[1]) }, 'invalid To datetime'], 182 | ], 183 | }, 184 | 'd' => { 185 | default => 0, 186 | rule => [ 187 | [['CHOICE',qw/1 0/],'invalid download flag'], 188 | ], 189 | }, 190 | 'stack' => { 191 | default => 0, 192 | rule => [ 193 | [['CHOICE',qw/1 0/],'invalid stack flag'], 194 | ], 195 | }, 196 | 'graphheader' => { 197 | default => 1, 198 | rule => [ 199 | [['CHOICE',qw/1 0/],'invalid graphheader flag'], 200 | ], 201 | }, 202 | 'graphlabel' => { 203 | default => 1, 204 | rule => [ 205 | [['CHOICE',qw/1 0/],'invalid graphlabel flag'], 206 | ], 207 | }, 208 | 'calculation' => { 209 | default => '', 210 | rule => [ 211 | [['CHOICE', map { $_->{function} } @{HRForecast::Calculator::CALCULATIONS()} ],'invalid calculation'], 212 | ], 213 | }, 214 | ]; 215 | 216 | sub _build_metrics_params { 217 | my $result = shift; 218 | 219 | my $term = $result->valid('t'); 220 | my @params; 221 | push @params, 't', $term; 222 | if ($term eq 'range') { 223 | push @params, $_ => $result->valid($_) for qw/period offset/; 224 | } 225 | elsif ($term eq 'c') { 226 | push @params, $_ => $result->valid($_) for qw/from to/; 227 | } 228 | 229 | my $calculation = $result->valid('calculation'); 230 | if ($calculation && ($calculation ne '')) { 231 | push @params, 'calculation', $calculation; 232 | } 233 | 234 | \@params; 235 | } 236 | 237 | sub create_merge_params { 238 | my $array_ref = shift; 239 | 240 | return sub { 241 | my $hash_ref = shift; 242 | my %params_hash = (@$array_ref, %$hash_ref); 243 | 244 | while (my ($key, $value) = each(%params_hash)){ 245 | if ($value eq '') { 246 | delete $params_hash{$key}; 247 | } 248 | } 249 | 250 | my @params_array = %params_hash; 251 | 252 | return \@params_array; 253 | } 254 | }; 255 | 256 | get '/list/:service_name/:section_name' => [qw/sidebar/] => sub { 257 | my ( $self, $c ) = @_; 258 | my $result = $c->req->validator($metrics_validator); 259 | my $rows = $self->data->get_metricses( 260 | $c->args->{service_name}, $c->args->{section_name} 261 | ); 262 | my ($from ,$to) = $self->calc_term( map {($_ => $result->valid($_))} qw/t from to period offset/); 263 | my $metrics_params = _build_metrics_params($result); 264 | $c->render('list.tx',{ 265 | metricses => $rows, 266 | valid => $result, 267 | metrics_params => $metrics_params, 268 | date_window => encode_json([$from->strftime('%Y/%m/%d %T'), 269 | $to->strftime('%Y/%m/%d %T')]), 270 | calculations => HRForecast::Calculator::CALCULATIONS, 271 | merge_params => HRForecast::Web::create_merge_params($metrics_params), 272 | }); 273 | }; 274 | 275 | get '/json/:service_name/:section_name' => sub { 276 | my ( $self, $c ) = @_; 277 | my $rows = $self->data->get_metricses( 278 | $c->args->{service_name}, $c->args->{section_name} 279 | ); 280 | $c->render_json({ 281 | error => 0, 282 | metricses => $rows 283 | }); 284 | }; 285 | 286 | 287 | get '/view/:service_name/:section_name/:graph_name' => [qw/sidebar get_metrics/] => sub { 288 | my ( $self, $c ) = @_; 289 | my $result = $c->req->validator($metrics_validator); 290 | my ($from ,$to) = $self->calc_term( map {($_ => $result->valid($_))} qw/t from to period offset/); 291 | my $metrics_params = _build_metrics_params($result); 292 | $c->render('list.tx', { 293 | metricses => [$c->stash->{metrics}], 294 | valid => $result, 295 | metrics_params => $metrics_params, 296 | date_window => encode_json([$from->strftime('%Y/%m/%d %T'), 297 | $to->strftime('%Y/%m/%d %T')]), 298 | calculations => HRForecast::Calculator::CALCULATIONS, 299 | merge_params => HRForecast::Web::create_merge_params($metrics_params), 300 | }); 301 | }; 302 | 303 | get '/json/:service_name/:section_name/:graph_name' => [qw/get_metrics/] => sub { 304 | my ( $self, $c ) = @_; 305 | $c->render_json({ 306 | error => 0, 307 | metricses => [$c->stash->{metrics}], 308 | }); 309 | }; 310 | 311 | 312 | get '/view_complex/:service_name/:section_name/:graph_name' => [qw/sidebar get_complex/] => sub { 313 | my ( $self, $c ) = @_; 314 | my $result = $c->req->validator($metrics_validator); 315 | my ($from ,$to) = $self->calc_term( map {($_ => $result->valid($_))} qw/t from to period offset/); 316 | my $metrics_params = _build_metrics_params($result); 317 | $c->render('list.tx', { 318 | metricses => [$c->stash->{metrics}], 319 | valid => $result, 320 | metrics_params => $metrics_params, 321 | date_window => encode_json([$from->strftime('%Y/%m/%d %T'), 322 | $to->strftime('%Y/%m/%d %T')]), 323 | calculations => HRForecast::Calculator::CALCULATIONS, 324 | merge_params => HRForecast::Web::create_merge_params($metrics_params), 325 | }); 326 | }; 327 | 328 | get '/json_complex/:service_name/:section_name/:graph_name' => [qw/get_complex/] => sub { 329 | my ( $self, $c ) = @_; 330 | $c->render_json({ 331 | error => 0, 332 | metricses => [$c->stash->{metrics}], 333 | }); 334 | }; 335 | 336 | get '/ifr/:service_name/:section_name/:graph_name' => [qw/unset_frame_option get_metrics/] => sub { 337 | my ( $self, $c ) = @_; 338 | my $result = $c->req->validator($metrics_validator); 339 | my ($from ,$to) = $self->calc_term( map {($_ => $result->valid($_))} qw/t from to period offset/); 340 | my $metrics_params = _build_metrics_params($result); 341 | $c->render('ifr.tx', { 342 | metrics => $c->stash->{metrics}, 343 | valid => $result, 344 | metrics_params => $metrics_params, 345 | date_window => encode_json([$from->strftime('%Y/%m/%d %T'), 346 | $to->strftime('%Y/%m/%d %T')]), 347 | calculations => HRForecast::Calculator::CALCULATIONS, 348 | merge_params => HRForecast::Web::create_merge_params($metrics_params), 349 | }); 350 | }; 351 | 352 | get '/ifr_complex/:service_name/:section_name/:graph_name' => [qw/unset_frame_option get_complex/] => sub { 353 | my ( $self, $c ) = @_; 354 | my $result = $c->req->validator($metrics_validator); 355 | my ($from ,$to) = $self->calc_term( map {($_ => $result->valid($_))} qw/t from to period offset/); 356 | my $metrics_params = _build_metrics_params($result); 357 | $c->render('ifr_complex.tx', { 358 | metrics => $c->stash->{metrics}, 359 | valid => $result, 360 | metrics_params => $metrics_params, 361 | date_window => encode_json([$from->strftime('%Y/%m/%d %T'), 362 | $to->strftime('%Y/%m/%d %T')]), 363 | calculations => HRForecast::Calculator::CALCULATIONS, 364 | merge_params => HRForecast::Web::create_merge_params($metrics_params), 365 | }); 366 | }; 367 | 368 | get '/ifr/preview/' => [qw/unset_frame_option/] => sub { 369 | my ( $self, $c ) = @_; 370 | $c->render('pifr_dummy.tx'); 371 | }; 372 | 373 | get '/ifr/preview/:complex' => [qw/unset_frame_option/] => sub { 374 | my ( $self, $c ) = @_; 375 | my $result = $c->req->validator($metrics_validator); 376 | my ($from ,$to) = $self->calc_term( map {($_ => $result->valid($_))} qw/t from to period offset/); 377 | 378 | my @complex = split /:/, $c->args->{complex}; 379 | my @colors; 380 | my @metricses; 381 | for my $id ( @complex ) { 382 | my $data = $self->data->get_by_id($id); 383 | push @metricses, $data; 384 | push @colors, $data ? $data->{color} : '#cccccc'; 385 | } 386 | 387 | $c->render('pifr.tx', { 388 | metricses => [@metricses], 389 | complex => $c->args->{complex}, 390 | valid => $result, 391 | metrics_params => _build_metrics_params($result), 392 | colors => encode_json(\@colors), 393 | date_window => encode_json([$from->strftime('%Y/%m/%d %T'), 394 | $to->strftime('%Y/%m/%d %T')]), 395 | }); 396 | }; 397 | 398 | get '/edit/:service_name/:section_name/:graph_name' => [qw/sidebar get_metrics/] => sub { 399 | my ( $self, $c ) = @_; 400 | $c->render('edit.tx'); 401 | }; 402 | 403 | post '/edit/:service_name/:section_name/:graph_name' => [qw/get_metrics/] => sub { 404 | my ( $self, $c ) = @_; 405 | my $check_uniq = sub { 406 | my ($req,$val) = @_; 407 | my $service = $req->param('service_name'); 408 | my $section = $req->param('section_name'); 409 | my $graph = $req->param('graph_name'); 410 | $service = '' if !defined $service; 411 | $section = '' if !defined $section; 412 | $graph = '' if !defined $graph; 413 | my $row = $self->data->get($service,$section,$graph); 414 | return 1 if $row && $row->{id} == $c->stash->{metrics}->{id}; 415 | return 1 if !$row; 416 | return; 417 | }; 418 | my $result = $c->req->validator([ 419 | 'service_name' => { 420 | rule => [ 421 | ['NOT_NULL', 'サービス名がありません'], 422 | ], 423 | }, 424 | 'section_name' => { 425 | rule => [ 426 | ['NOT_NULL', 'セクション名がありません'], 427 | ], 428 | }, 429 | 'graph_name' => { 430 | rule => [ 431 | ['NOT_NULL', 'グラフ名がありません'], 432 | [$check_uniq,'同じ名前のグラフがあります'], 433 | ], 434 | }, 435 | 'description' => { 436 | default => '', 437 | rule => [], 438 | }, 439 | 'sort' => { 440 | rule => [ 441 | ['NOT_NULL', '値がありません'], 442 | [['CHOICE',0..19], '値が正しくありません'], 443 | ], 444 | }, 445 | 'color' => { 446 | rule => [ 447 | ['NOT_NULL', '正しくありません'], 448 | [sub{ $_[1] =~ m!^#[0-9A-F]{6}$!i }, '#000000の形式で入力してください'], 449 | ], 450 | }, 451 | ]); 452 | if ( $result->has_error ) { 453 | my $res = $c->render_json({ 454 | error => 1, 455 | messages => $result->errors 456 | }); 457 | return $res; 458 | } 459 | 460 | $self->data->update_metrics( 461 | $c->stash->{metrics}->{id}, 462 | $result->valid->as_hashref 463 | ); 464 | 465 | my $row = $self->data->get( 466 | $c->args->{service_name}, $c->args->{section_name}, $c->args->{graph_name}, 467 | ); 468 | 469 | $c->render_json({ 470 | error => 0, 471 | metricses => [$row], 472 | location => $c->req->uri_for( 473 | '/list/'.$result->valid('service_name').'/'.$result->valid('section_name'))->as_string, 474 | }); 475 | }; 476 | 477 | post '/delete/:service_name/:section_name/:graph_name' => [qw/get_metrics/] => sub { 478 | my ( $self, $c ) = @_; 479 | $self->data->delete_metrics( 480 | $c->stash->{metrics}->{id}, 481 | ); 482 | $c->render_json({ 483 | error => 0, 484 | location => $c->req->uri_for( 485 | '/list/'.$c->args->{service_name}.'/'.$c->args->{section_name})->as_string, 486 | }); 487 | }; 488 | 489 | get '/add_complex' => [qw/sidebar/] => sub { 490 | my ( $self, $c ) = @_; 491 | my $all_metrics_names = $self->data->get_all_metrics_name(); 492 | $c->render('add_complex.tx', { all_metrics_names => $all_metrics_names } ); 493 | }; 494 | 495 | sub check_uniq_complex { 496 | my ($self,$id) = @_; 497 | sub { 498 | my ($req,$val) = @_; 499 | my $service = $req->param('service_name'); 500 | my $section = $req->param('section_name'); 501 | my $graph = $req->param('graph_name'); 502 | $service = '' if !defined $service; 503 | $section = '' if !defined $section; 504 | $graph = '' if !defined $graph; 505 | my $row = $self->data->get_complex($service,$section,$graph); 506 | if ($id) { 507 | return 1 if $row && $row->{id} == $id; 508 | } 509 | return 1 if !$row; 510 | return; 511 | }; 512 | } 513 | 514 | post '/add_complex' => sub { 515 | my ( $self, $c ) = @_; 516 | my $result = $c->req->validator([ 517 | 'service_name' => { 518 | rule => [ 519 | ['NOT_NULL', 'サービス名がありません'], 520 | ], 521 | }, 522 | 'section_name' => { 523 | rule => [ 524 | ['NOT_NULL', 'セクション名がありません'], 525 | ], 526 | }, 527 | 'graph_name' => { 528 | rule => [ 529 | ['NOT_NULL', 'グラフ名がありません'], 530 | [$self->check_uniq_complex,'同じ名前のグラフがあります'], 531 | ], 532 | }, 533 | 'description' => { 534 | default => '', 535 | rule => [], 536 | }, 537 | 'stack' => { 538 | rule => [ 539 | ['NOT_NULL', 'スタックの値がありません'], 540 | [['CHOICE',0,1], 'スタックの値が正しくありません'], 541 | ], 542 | }, 543 | 'sort' => { 544 | rule => [ 545 | ['NOT_NULL', 'ソートの値がありません'], 546 | [['CHOICE',0..19], 'ソートの値が正しくありません'], 547 | ], 548 | }, 549 | '@path-data' => { 550 | rule => [ 551 | [['@SELECTED_NUM',1,100], 'データは100件までにしてください'], 552 | ['NOT_NULL','データが正しくありません'], 553 | ['NATURAL', 'データが正しくありません'], 554 | ], 555 | }, 556 | ]); 557 | if ( $result->has_error ) { 558 | my $res = $c->render_json({ 559 | error => 1, 560 | messages => $result->errors 561 | }); 562 | return $res; 563 | } 564 | 565 | $self->data->create_complex( 566 | $result->valid('service_name'),$result->valid('section_name'),$result->valid('graph_name'), 567 | $result->valid->mixed 568 | ); 569 | 570 | my $row = $self->data->get_complex( 571 | $c->args->{service_name}, $c->args->{section_name}, $c->args->{graph_name}, 572 | ); 573 | 574 | $c->render_json({ 575 | error => 0, 576 | metricses => [$row], 577 | location => $c->req->uri_for('/list/'.$result->valid('service_name').'/'.$result->valid('section_name'))->as_string, 578 | }); 579 | }; 580 | 581 | get '/edit_complex/:service_name/:section_name/:graph_name' => [qw/sidebar get_complex/] => sub { 582 | my ( $self, $c ) = @_; 583 | my $all_metrics_names = $self->data->get_all_metrics_name(); 584 | $c->render('edit_complex.tx', { all_metrics_names => $all_metrics_names } ); 585 | }; 586 | 587 | post '/edit_complex/:service_name/:section_name/:graph_name' => [qw/sidebar get_complex/] => sub { 588 | my ( $self, $c ) = @_; 589 | my $result = $c->req->validator([ 590 | 'service_name' => { 591 | rule => [ 592 | ['NOT_NULL', 'サービス名がありません'], 593 | ], 594 | }, 595 | 'section_name' => { 596 | rule => [ 597 | ['NOT_NULL', 'セクション名がありません'], 598 | ], 599 | }, 600 | 'graph_name' => { 601 | rule => [ 602 | ['NOT_NULL', 'グラフ名がありません'], 603 | [$self->check_uniq_complex($c->stash->{metrics}->{id}),'同じ名前のグラフがあります'], 604 | ], 605 | }, 606 | 'description' => { 607 | default => '', 608 | rule => [], 609 | }, 610 | 'stack' => { 611 | rule => [ 612 | ['NOT_NULL', 'スタックの値がありません'], 613 | [['CHOICE',0,1], 'スタックの値が正しくありません'], 614 | ], 615 | }, 616 | 'sort' => { 617 | rule => [ 618 | ['NOT_NULL', 'ソートの値がありません'], 619 | [['CHOICE',0..19], 'ソートの値が正しくありません'], 620 | ], 621 | }, 622 | '@path-data' => { 623 | rule => [ 624 | [['@SELECTED_NUM',1,100], 'データは100件までにしてください'], 625 | ['NOT_NULL','データが正しくありません'], 626 | ['NATURAL', 'データが正しくありません'], 627 | ], 628 | }, 629 | ]); 630 | if ( $result->has_error ) { 631 | my $res = $c->render_json({ 632 | error => 1, 633 | messages => $result->errors 634 | }); 635 | return $res; 636 | } 637 | 638 | $self->data->update_complex( 639 | $c->stash->{metrics}->{id}, 640 | $result->valid->mixed 641 | ); 642 | 643 | my $row = $self->data->get_complex( 644 | $c->args->{service_name}, $c->args->{section_name}, $c->args->{graph_name}, 645 | ); 646 | 647 | $c->render_json({ 648 | error => 0, 649 | metricses => [$row], 650 | location => $c->req->uri_for('/list/'.$result->valid('service_name').'/'.$result->valid('section_name'))->as_string, 651 | }); 652 | }; 653 | 654 | 655 | post '/delete_complex/:service_name/:section_name/:graph_name' => [qw/get_complex/] => sub { 656 | my ( $self, $c ) = @_; 657 | $self->data->delete_complex( 658 | $c->stash->{metrics}->{id}, 659 | ); 660 | $c->render_json({ 661 | error => 0, 662 | location => $c->req->uri_for( 663 | '/list/'.$c->args->{service_name}.'/'.$c->args->{section_name})->as_string, 664 | }); 665 | }; 666 | 667 | my $display_csv = sub { 668 | my ( $self, $c ) = @_; 669 | my $result = $c->req->validator($metrics_validator); 670 | my ($from ,$to) = $self->calc_term( map {($_ => $result->valid($_))} qw/t from to period offset/); 671 | 672 | my $calculator = HRForecast::Calculator->new(); 673 | my $rows = $calculator->calculate($self->data, $c->stash->{metrics}->{id}, $from ,$to, $result->valid('calculation')); 674 | 675 | my @result; 676 | push @result, [ 677 | 'Date', 678 | sprintf("/%s/%s/%s",map { $c->stash->{metrics}->{$_} } qw/service_name section_name graph_name/) 679 | ]; 680 | foreach my $row ( @$rows ) { 681 | push @result, [ 682 | $row->{datetime}->strftime('%Y/%m/%d %T'), 683 | $row->{number} 684 | ]; 685 | } 686 | 687 | if ( $c->stash->{display_table} ) { 688 | return $c->render('table.tx', { table => \@result }); 689 | } 690 | 691 | if ( $result->valid('d') ) { 692 | $c->res->header('Content-Disposition', 693 | sprintf('attachment; filename="metrics_%s.csv"',$c->stash->{metrics}->{id})); 694 | $c->res->content_type('application/octet-stream'); 695 | } 696 | else { 697 | $c->res->content_type('text/plain'); 698 | } 699 | 700 | $c->res->body( join "\n", map { join ",", @$_ } @result ); 701 | $c->res; 702 | }; 703 | 704 | my $display_complex_csv = sub { 705 | my ( $self, $c ) = @_; 706 | my $result = $c->req->validator($metrics_validator); 707 | my ($from ,$to) = $self->calc_term( map {($_ => $result->valid($_))} qw/t from to period offset/); 708 | 709 | my @data; 710 | my @id; 711 | if ( !$c->stash->{metrics} ) { 712 | my @complex = split /:/, $c->args->{complex}; 713 | for my $id ( @complex ) { 714 | my $data = $self->data->get_by_id($id); 715 | next unless $data; 716 | push @data, $data; 717 | push @id, $data->{id}; 718 | } 719 | } 720 | else { 721 | @data = @{$c->stash->{metrics}->{metricses}}; 722 | @id = map { $_->{id} } @data; 723 | } 724 | 725 | my $calculator = HRForecast::Calculator->new(); 726 | my $rows = $calculator->calculate($self->data, [ map { $_->{id} } @data ], $from, $to, $result->valid('calculation')); 727 | 728 | my %date_group; 729 | foreach my $row ( @$rows ) { 730 | my $datetime = $row->{datetime}->strftime('%Y%m%d%H%M%S'); 731 | $date_group{$datetime} ||= {}; 732 | $date_group{$datetime}->{$row->{metrics_id}} = $row->{number}; 733 | } 734 | 735 | my @result; 736 | push @result, [ 737 | 'Date', 738 | map { '/'.$_->{service_name}.'/'.$_->{section_name}.'/'.$_->{graph_name} } @data 739 | ]; 740 | 741 | foreach my $key ( sort keys %date_group ) { 742 | $key =~ m!^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$!; 743 | my $datetime = sprintf "%s/%s/%s %s:%s:%s", $1, $2, $3, $4, $5, $6; 744 | 745 | push @result, [ 746 | $datetime, 747 | map { exists $date_group{$key}->{$_} ? $date_group{$key}->{$_} : 0 } @id 748 | ]; 749 | } 750 | 751 | if ( $c->stash->{display_table} ) { 752 | return $c->render('table.tx', { table => \@result }); 753 | } 754 | 755 | 756 | if ( $result->valid('d') ) { 757 | $c->res->header('Content-Disposition', 758 | sprintf('attachment; filename="metrics_%02d.csv"', int(rand(100)) )); 759 | $c->res->content_type('application/octet-stream'); 760 | } 761 | else { 762 | $c->res->content_type('text/plain'); 763 | } 764 | $c->res->body( join "\n", map { join ",", @$_ } @result ); 765 | $c->res; 766 | }; 767 | 768 | 769 | get '/csv/:service_name/:section_name/:graph_name' 770 | => [qw/get_metrics/] 771 | => $display_csv; 772 | get '/table/:service_name/:section_name/:graph_name' 773 | => [qw/get_metrics display_table/] 774 | => $display_csv; 775 | 776 | get '/csv/:complex' => $display_complex_csv; 777 | get '/csv_complex/:service_name/:section_name/:graph_name' 778 | => [qw/get_complex/] 779 | => $display_complex_csv; 780 | get '/table/:complex' => [qw/display_table/] => $display_complex_csv; 781 | get '/table_complex/:service_name/:section_name/:graph_name' 782 | => [qw/get_complex display_table/] 783 | => $display_complex_csv; 784 | 785 | 786 | post '/api/:service_name/:section_name/:graph_name' => sub { 787 | my ( $self, $c ) = @_; 788 | my $result = $c->req->validator([ 789 | 'number' => { 790 | rule => [ 791 | ['NOT_NULL','number is null'], 792 | ['INT','number is not int'] 793 | ], 794 | }, 795 | 'datetime' => { 796 | default => sub { HTTP::Date::time2str(time) }, 797 | rule => [ 798 | [ sub { HTTP::Date::str2time($_[1]) } ,'datetime is not null'] 799 | ], 800 | }, 801 | ]); 802 | 803 | if ( $result->has_error ) { 804 | my $res = $c->render_json({ 805 | error => 1, 806 | messages => $result->messages 807 | }); 808 | $res->status(400); 809 | return $res; 810 | } 811 | 812 | my $ret = $self->data->update( 813 | $c->args->{service_name}, $c->args->{section_name}, $c->args->{graph_name}, 814 | $result->valid('number'), HTTP::Date::str2time($result->valid('datetime')) 815 | ); 816 | my $row = $self->data->get( 817 | $c->args->{service_name}, $c->args->{section_name}, $c->args->{graph_name}, 818 | ); 819 | 820 | $c->render_json({ 821 | error => 0, 822 | metricses => [$row], 823 | }); 824 | }; 825 | 826 | 827 | 828 | 829 | 1; 830 | 831 | --------------------------------------------------------------------------------