mirror of
https://github.com/getredash/redash.git
synced 2025-12-25 01:03:20 -05:00
Compare commits
1406 Commits
v0.2.64
...
v0.4.0+b62
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e85d3c3c9f | ||
|
|
e20f57bba8 | ||
|
|
933ace2e38 | ||
|
|
4c1e5aed6b | ||
|
|
77d982b4aa | ||
|
|
02c8163265 | ||
|
|
ef868dbb6e | ||
|
|
b2bab33baa | ||
|
|
149e0835f8 | ||
|
|
50bed1d8f2 | ||
|
|
d4b5d78743 | ||
|
|
7fc82a2562 | ||
|
|
92fb138c2c | ||
|
|
71b4b45a3c | ||
|
|
07f4a1b227 | ||
|
|
e116e88e98 | ||
|
|
2278a181ca | ||
|
|
98dc75a404 | ||
|
|
536918aab3 | ||
|
|
c75ac80c7a | ||
|
|
522d8542e9 | ||
|
|
562df44c22 | ||
|
|
86e6798c96 | ||
|
|
db7a287e82 | ||
|
|
518206f208 | ||
|
|
bcee1e12b4 | ||
|
|
410f4f35e2 | ||
|
|
84ea9fec43 | ||
|
|
cda82b7adc | ||
|
|
f2d8c2020b | ||
|
|
1b82ecbc46 | ||
|
|
e381331c36 | ||
|
|
ff58247987 | ||
|
|
dcf0d2cbe3 | ||
|
|
eb99fa5671 | ||
|
|
ce3e19f212 | ||
|
|
44dca6da01 | ||
|
|
34c9fee540 | ||
|
|
e0b13b2ffa | ||
|
|
df362c12b6 | ||
|
|
0d1f8c948a | ||
|
|
f523378326 | ||
|
|
b0f9e49709 | ||
|
|
b6dbb4e3f8 | ||
|
|
3f6a0e8ffa | ||
|
|
a7bcc6d31e | ||
|
|
8aa2d8e70a | ||
|
|
4720e12be7 | ||
|
|
5463591f0d | ||
|
|
2a0198fba8 | ||
|
|
652f214b25 | ||
|
|
aa49780134 | ||
|
|
f483b61cfb | ||
|
|
38a189b671 | ||
|
|
c2331988db | ||
|
|
eff5bdb454 | ||
|
|
bd1babec3a | ||
|
|
d43c2bbf62 | ||
|
|
87db8099d6 | ||
|
|
ebea118c7d | ||
|
|
297ac5c9bd | ||
|
|
9b23fb4235 | ||
|
|
0a71f5e22d | ||
|
|
0a8aaceb85 | ||
|
|
00979f3ad7 | ||
|
|
c7b48837f2 | ||
|
|
418c5322c1 | ||
|
|
dc5b4c26a3 | ||
|
|
9ed0a5ba85 | ||
|
|
db0770fc17 | ||
|
|
9bb58e71d2 | ||
|
|
560598eaad | ||
|
|
f9144fc927 | ||
|
|
883bf173c0 | ||
|
|
3f2bb65b32 | ||
|
|
3917af019a | ||
|
|
e88837e835 | ||
|
|
7abdc2543e | ||
|
|
91ab90a6fe | ||
|
|
7fd2bd3d24 | ||
|
|
3ed1ea1e33 | ||
|
|
a4486c56b9 | ||
|
|
3da0ecf36c | ||
|
|
11a1095b18 | ||
|
|
b43485f322 | ||
|
|
d83675326b | ||
|
|
8d7b9a552e | ||
|
|
e1eb75b786 | ||
|
|
34a3c9e91c | ||
|
|
e007a2891d | ||
|
|
febe6e4aa7 | ||
|
|
8099dafc68 | ||
|
|
ce3d5e637f | ||
|
|
4a52ccd4fa | ||
|
|
a0c81f8a31 | ||
|
|
ce13b79bdc | ||
|
|
c580db277d | ||
|
|
5e944e9a8f | ||
|
|
4b94cf706a | ||
|
|
364c51456d | ||
|
|
1274d36abc | ||
|
|
f6bd562dd2 | ||
|
|
065d2bc2c6 | ||
|
|
653ed1c57a | ||
|
|
7dc1176628 | ||
|
|
365b8a8c93 | ||
|
|
6e1e0a9967 | ||
|
|
170640a63f | ||
|
|
5e970b73d5 | ||
|
|
a4643472a5 | ||
|
|
7aa01f2bd2 | ||
|
|
cb4b0e0296 | ||
|
|
2c05e921c4 | ||
|
|
c4877f254e | ||
|
|
9fc59de35f | ||
|
|
eb50f3fc94 | ||
|
|
12fe59827f | ||
|
|
d32caff31d | ||
|
|
ba540ff380 | ||
|
|
2112faab02 | ||
|
|
34c6be398a | ||
|
|
3f9c2a5592 | ||
|
|
8076b7f0b7 | ||
|
|
8940d66b0b | ||
|
|
948e2247e4 | ||
|
|
eba2ba1918 | ||
|
|
59d5ba9273 | ||
|
|
4aba24a976 | ||
|
|
762c331ddf | ||
|
|
9592610f8b | ||
|
|
8b7399ddc9 | ||
|
|
f6221da9dc | ||
|
|
10c84d2cd0 | ||
|
|
60d784d7bc | ||
|
|
b28e4be8d7 | ||
|
|
e74b36996f | ||
|
|
4c28d11259 | ||
|
|
b1e1a32f37 | ||
|
|
a12b43265d | ||
|
|
c2d621ae0f | ||
|
|
d93e07061b | ||
|
|
cb59973b9a | ||
|
|
72e41a94e4 | ||
|
|
9013497fc7 | ||
|
|
a74ae32122 | ||
|
|
9cfae349da | ||
|
|
a16718917b | ||
|
|
e2e365d9ff | ||
|
|
5310498d0f | ||
|
|
bb1d2f8805 | ||
|
|
0d5f001d38 | ||
|
|
236f7f9c04 | ||
|
|
74bf8e5239 | ||
|
|
71e125b4b0 | ||
|
|
6a8befc641 | ||
|
|
a79aa382d7 | ||
|
|
5698f9692a | ||
|
|
b2381f6933 | ||
|
|
9a732a4dbf | ||
|
|
17eb7e4146 | ||
|
|
16a6c96c22 | ||
|
|
bc0a5160ac | ||
|
|
62ab1fda80 | ||
|
|
b5309833ee | ||
|
|
7b932507a6 | ||
|
|
c9fda5e6f1 | ||
|
|
a274bde092 | ||
|
|
b4024ec880 | ||
|
|
6367943d31 | ||
|
|
eaa83556c3 | ||
|
|
7e720bcecd | ||
|
|
003c285d11 | ||
|
|
54687e72bd | ||
|
|
8c59386dc9 | ||
|
|
0369c557a4 | ||
|
|
1ca95dc497 | ||
|
|
85ea9060b0 | ||
|
|
19b4ec7102 | ||
|
|
b2fea7f2fe | ||
|
|
d5947669ab | ||
|
|
4cb97db98e | ||
|
|
9b5d43067a | ||
|
|
8731a8d273 | ||
|
|
08a06b0792 | ||
|
|
90157157df | ||
|
|
f5ea1f1559 | ||
|
|
cf89e6b184 | ||
|
|
5920747122 | ||
|
|
2fff4f4036 | ||
|
|
442ece5a4f | ||
|
|
4bbf04b68a | ||
|
|
f74af231ce | ||
|
|
ffa679e04b | ||
|
|
8f1d267c00 | ||
|
|
af61517384 | ||
|
|
15a7374a4b | ||
|
|
c0fe4a7c84 | ||
|
|
2a18c4493b | ||
|
|
fc60c1b86a | ||
|
|
5b998269b3 | ||
|
|
914378cc65 | ||
|
|
30f98e9796 | ||
|
|
2b524075d9 | ||
|
|
3641e332b0 | ||
|
|
4ce3f4eaa9 | ||
|
|
0b173e67a5 | ||
|
|
2af234d180 | ||
|
|
d751fd8c8c | ||
|
|
35552f9b77 | ||
|
|
1cc36b481a | ||
|
|
c9b95bc359 | ||
|
|
86d64c35ab | ||
|
|
8712c8567c | ||
|
|
b0cc646b5e | ||
|
|
8e1c852b0d | ||
|
|
349f67337d | ||
|
|
4af979d3eb | ||
|
|
727cc67f19 | ||
|
|
f51df00564 | ||
|
|
8d7044a81a | ||
|
|
d1c62b106d | ||
|
|
a1dcf94d4d | ||
|
|
53fc9bbf54 | ||
|
|
7755e9859d | ||
|
|
21f3a80940 | ||
|
|
06910d9002 | ||
|
|
5777070bec | ||
|
|
8e3adcd283 | ||
|
|
381ab62505 | ||
|
|
93491004e2 | ||
|
|
d1f0ae9538 | ||
|
|
94bb55d66b | ||
|
|
9de6996dc8 | ||
|
|
9636359497 | ||
|
|
9a6b40aff9 | ||
|
|
82dee49a43 | ||
|
|
9b4482f25d | ||
|
|
4caf1ac3d3 | ||
|
|
0cda4a6632 | ||
|
|
a80618fbe2 | ||
|
|
310808f1fb | ||
|
|
939168773a | ||
|
|
c6a415535e | ||
|
|
ce87c7b736 | ||
|
|
036eb46ea4 | ||
|
|
95ad15057b | ||
|
|
459309ee4e | ||
|
|
4e0069810e | ||
|
|
5a62e90f17 | ||
|
|
cf689c424f | ||
|
|
dad9eb21a0 | ||
|
|
8b581368dc | ||
|
|
ca093ec235 | ||
|
|
c6e210f107 | ||
|
|
e2d0285496 | ||
|
|
16125327b1 | ||
|
|
d8d666c971 | ||
|
|
772ea94b59 | ||
|
|
e499e8099d | ||
|
|
75bc9bb318 | ||
|
|
f79362c7a3 | ||
|
|
2c34ecde35 | ||
|
|
1610d9b782 | ||
|
|
17dd4efb27 | ||
|
|
7a2af73bea | ||
|
|
81d027611f | ||
|
|
9ef941bc63 | ||
|
|
cb0d27e691 | ||
|
|
03767bbc0a | ||
|
|
0042b73cd9 | ||
|
|
1c095bcd99 | ||
|
|
4287d9a2e2 | ||
|
|
e297faab7c | ||
|
|
c0329cc0ef | ||
|
|
dc7050d4ef | ||
|
|
3a2f2be95d | ||
|
|
b4432ee21d | ||
|
|
d9b0e84bbe | ||
|
|
e8c946b88b | ||
|
|
7b94260135 | ||
|
|
51c59dad63 | ||
|
|
2d398696d0 | ||
|
|
ceb08808f8 | ||
|
|
e7c6ba8c1d | ||
|
|
3cee9c9b3a | ||
|
|
509edf651b | ||
|
|
28224a0ba1 | ||
|
|
4e8cd93905 | ||
|
|
069fe38354 | ||
|
|
05c915cf00 | ||
|
|
37512b5fdd | ||
|
|
0fa22500be | ||
|
|
3fbc73d181 | ||
|
|
4d4f41733d | ||
|
|
113821cc97 | ||
|
|
3f9ba7ff00 | ||
|
|
37bf79c9eb | ||
|
|
073deb8315 | ||
|
|
38293fc155 | ||
|
|
7793b3fe41 | ||
|
|
52f44588e6 | ||
|
|
25de0303a1 | ||
|
|
0ffda9d002 | ||
|
|
a37aa11baf | ||
|
|
e7331633a4 | ||
|
|
1ae40981fe | ||
|
|
19743f387b | ||
|
|
17bb5eac91 | ||
|
|
77d628d2db | ||
|
|
e5348bcf9f | ||
|
|
bcce69904d | ||
|
|
ee7e452c70 | ||
|
|
7b4c04024c | ||
|
|
73402a4f3c | ||
|
|
a40da45b1e | ||
|
|
42a3309731 | ||
|
|
638fb123ec | ||
|
|
f2e06e6191 | ||
|
|
f95a09a015 | ||
|
|
a10a38575b | ||
|
|
b74f4639a0 | ||
|
|
c7efe3a99f | ||
|
|
a7b10db3f4 | ||
|
|
cc544e9343 | ||
|
|
0a301bd997 | ||
|
|
2abffff9fd | ||
|
|
174eb2408e | ||
|
|
e91c9a00b1 | ||
|
|
3b6af18009 | ||
|
|
c9608dfa4f | ||
|
|
ab2fa1e352 | ||
|
|
bd0b5c7136 | ||
|
|
9a025a7e05 | ||
|
|
d198a99419 | ||
|
|
96081de51f | ||
|
|
16c461c15f | ||
|
|
1bf56899f3 | ||
|
|
c874a2218b | ||
|
|
79b4c86520 | ||
|
|
d92d994532 | ||
|
|
1704914d6b | ||
|
|
9c43b55668 | ||
|
|
cddd7e909d | ||
|
|
9a6852db78 | ||
|
|
2270042c0f | ||
|
|
6ae3a7552a | ||
|
|
8e5e37ee1b | ||
|
|
146131761f | ||
|
|
855aecd85f | ||
|
|
cdf6a1994b | ||
|
|
a7ce5246a6 | ||
|
|
6efd830bd4 | ||
|
|
a8ea811fed | ||
|
|
f39a848aa2 | ||
|
|
a71b99a873 | ||
|
|
9f2fc1f90a | ||
|
|
391c220604 | ||
|
|
fd9d71b927 | ||
|
|
e5bf431987 | ||
|
|
ba8a39db57 | ||
|
|
f23b434972 | ||
|
|
191ad19cac | ||
|
|
ef366df1fb | ||
|
|
14112fd45b | ||
|
|
2caf02b4e0 | ||
|
|
676cf32c22 | ||
|
|
b7a0b7454a | ||
|
|
289d38b2a6 | ||
|
|
fa2986a154 | ||
|
|
850ac9f4c8 | ||
|
|
084e9f8394 | ||
|
|
4ffd21be09 | ||
|
|
3e87fff8b1 | ||
|
|
a37c1eb589 | ||
|
|
7d0324be91 | ||
|
|
63c85deb5c | ||
|
|
2938e57980 | ||
|
|
ac89584083 | ||
|
|
413dd61491 | ||
|
|
74f9d85752 | ||
|
|
08d6a90469 | ||
|
|
b85c535c6f | ||
|
|
f50799cc7b | ||
|
|
e8aba6b682 | ||
|
|
a2dbc76116 | ||
|
|
163ee33ae6 | ||
|
|
83933e24ac | ||
|
|
a9f24669b7 | ||
|
|
638df29d95 | ||
|
|
73d99031b7 | ||
|
|
2e01d57c9b | ||
|
|
6f6c1678ff | ||
|
|
d26b822f6c | ||
|
|
976dc1e496 | ||
|
|
c49fbe1ac2 | ||
|
|
6a7e322b97 | ||
|
|
4b6b1984aa | ||
|
|
0e564bc8f8 | ||
|
|
8a546b4193 | ||
|
|
6fe733aeaa | ||
|
|
31c09dd7ce | ||
|
|
af18670131 | ||
|
|
98f0bc0188 | ||
|
|
362e5b820e | ||
|
|
36d27dfd74 | ||
|
|
2204c437a2 | ||
|
|
9edd8313ec | ||
|
|
95bcffc28a | ||
|
|
790cbd95b1 | ||
|
|
efdaf4cf3a | ||
|
|
5dd8b102e1 | ||
|
|
04d92ce14b | ||
|
|
43496ecdb2 | ||
|
|
fec6c8b6a7 | ||
|
|
ff099b4314 | ||
|
|
78da5ae92e | ||
|
|
6ab4c4551a | ||
|
|
59a8c0c2c2 | ||
|
|
851c080c13 | ||
|
|
cb800c5907 | ||
|
|
0daf715152 | ||
|
|
31cc6fdaeb | ||
|
|
e335398ba7 | ||
|
|
1a8611a3c0 | ||
|
|
8178900d56 | ||
|
|
258e3c957d | ||
|
|
9f9d78fd7a | ||
|
|
1d83021ab3 | ||
|
|
d9af5d3943 | ||
|
|
7ed9dc90d3 | ||
|
|
433e004295 | ||
|
|
f3628f7bba | ||
|
|
314a75f8a2 | ||
|
|
185b1c9df0 | ||
|
|
a686baa372 | ||
|
|
881e44fbb6 | ||
|
|
a4518dc2aa | ||
|
|
d7e1328fc0 | ||
|
|
9b8c3872c6 | ||
|
|
2c7a6004c0 | ||
|
|
5a0f524b5e | ||
|
|
6d62f0d2c9 | ||
|
|
0551e992fa | ||
|
|
8615429e0c | ||
|
|
1b0d315b30 | ||
|
|
bd67c2ff21 | ||
|
|
577fdffc7f | ||
|
|
65e8bef22c | ||
|
|
241d31f608 | ||
|
|
c84f18449b | ||
|
|
57a23a1181 | ||
|
|
718577f565 | ||
|
|
c2e4e19004 | ||
|
|
69f14c3a61 | ||
|
|
52441ec5b4 | ||
|
|
fcda122107 | ||
|
|
01b908539b | ||
|
|
d7f6b589cd | ||
|
|
eca62cd1f2 | ||
|
|
4de9bf2d61 | ||
|
|
67ec5614e1 | ||
|
|
599f12fdc2 | ||
|
|
a92ef02b07 | ||
|
|
18d16bb92d | ||
|
|
45d11d3227 | ||
|
|
26365054bf | ||
|
|
3cefa004cd | ||
|
|
58a22c0a97 | ||
|
|
d3852db164 | ||
|
|
cce4a08b54 | ||
|
|
b242295de0 | ||
|
|
f80a940ff4 | ||
|
|
a37142426c | ||
|
|
794d8ddfcf | ||
|
|
271d577074 | ||
|
|
7adf4bf763 | ||
|
|
2fd3033418 | ||
|
|
e50aa536c2 | ||
|
|
74de143636 | ||
|
|
2d3348b1a9 | ||
|
|
81ca8b9012 | ||
|
|
df733d3e9c | ||
|
|
0167bebf04 | ||
|
|
b1d6a5a45a | ||
|
|
5de1795380 | ||
|
|
3bb26c5906 | ||
|
|
99a9fdde25 | ||
|
|
e2f9b7565b | ||
|
|
3e6dd8e929 | ||
|
|
6556f22e91 | ||
|
|
c0fc7c8222 | ||
|
|
e5377abf0f | ||
|
|
1eb2d562a5 | ||
|
|
b4625f1c78 | ||
|
|
82f5f15c2a | ||
|
|
63037c62a0 | ||
|
|
a696e10ef7 | ||
|
|
617bbc213f | ||
|
|
87933bd8ac | ||
|
|
9e3cb6e581 | ||
|
|
29f01a5780 | ||
|
|
d4dfc67059 | ||
|
|
23a3a7f20e | ||
|
|
5ec2d2fe97 | ||
|
|
b2e7813d87 | ||
|
|
0b093415ca | ||
|
|
ff9fadd55a | ||
|
|
77f226e4a2 | ||
|
|
40adba4242 | ||
|
|
71a4d5288d | ||
|
|
d4d118af17 | ||
|
|
72c74101da | ||
|
|
ace657d95a | ||
|
|
1bb12b87ac | ||
|
|
fd3e9e3fcb | ||
|
|
ec40436a65 | ||
|
|
3243f277f2 | ||
|
|
7cd129db52 | ||
|
|
7ac76c2996 | ||
|
|
904c54003d | ||
|
|
84b0590ec5 | ||
|
|
ba63048fc0 | ||
|
|
a46c651dad | ||
|
|
ecb80df10a | ||
|
|
11ba93cc80 | ||
|
|
782919788d | ||
|
|
23760ffa86 | ||
|
|
37dbdf494f | ||
|
|
5ad2bd048c | ||
|
|
9717a686be | ||
|
|
839abe627e | ||
|
|
55167adef6 | ||
|
|
9305b76b85 | ||
|
|
001e2a8887 | ||
|
|
61a196fafc | ||
|
|
a503e20c92 | ||
|
|
0a05d31b17 | ||
|
|
80a5804c9c | ||
|
|
001950a116 | ||
|
|
89cbaf0ac5 | ||
|
|
3670c7c3a7 | ||
|
|
f2f61a1fc9 | ||
|
|
3dc8d9a842 | ||
|
|
b93132e5d9 | ||
|
|
fbb8943eeb | ||
|
|
156bf96788 | ||
|
|
84d07903f6 | ||
|
|
4d1908dceb | ||
|
|
1571676d7a | ||
|
|
870cc142a9 | ||
|
|
8cb0472497 | ||
|
|
eade74ffb0 | ||
|
|
de41dc84af | ||
|
|
880412da94 | ||
|
|
5ae2b88cec | ||
|
|
a9dae21483 | ||
|
|
0a22fb61dc | ||
|
|
0578273f7e | ||
|
|
5d37f1a34b | ||
|
|
cf9fe300fe | ||
|
|
bbe17f3a09 | ||
|
|
1bea6a9627 | ||
|
|
21ad5bbb4a | ||
|
|
5ce4fcb974 | ||
|
|
977193b009 | ||
|
|
028a3e9d62 | ||
|
|
16a83f6134 | ||
|
|
fa2438f40d | ||
|
|
e0af1f20af | ||
|
|
10bccfb4ad | ||
|
|
ca415c50ad | ||
|
|
3c0972b8ac | ||
|
|
c4cbe06c12 | ||
|
|
98ac23a843 | ||
|
|
34fb58d403 | ||
|
|
df458c1052 | ||
|
|
cddf69e422 | ||
|
|
dd86711b32 | ||
|
|
6a1c5aeae7 | ||
|
|
4493d22ec9 | ||
|
|
f3411a46a5 | ||
|
|
5ffd2615e7 | ||
|
|
7616738fc6 | ||
|
|
e996b4fa22 | ||
|
|
5d03ce6b50 | ||
|
|
bcca2aa341 | ||
|
|
3ad8114a28 | ||
|
|
602d935559 | ||
|
|
37d56a2bf6 | ||
|
|
af9318fbd1 | ||
|
|
cff07a3e3d | ||
|
|
2ba4bcd98e | ||
|
|
a1f81705dd | ||
|
|
fac9082a03 | ||
|
|
b8dba48759 | ||
|
|
9ac335116c | ||
|
|
ae8706ab85 | ||
|
|
fbc325bf07 | ||
|
|
af85943c08 | ||
|
|
cad34f63bf | ||
|
|
d7a453e8b1 | ||
|
|
d9964d84b3 | ||
|
|
725a8f2bb5 | ||
|
|
9379f76562 | ||
|
|
5979d91875 | ||
|
|
21e02ee04e | ||
|
|
86b95a404a | ||
|
|
214806d31b | ||
|
|
366cdbf616 | ||
|
|
cea1a73ad6 | ||
|
|
addaf97489 | ||
|
|
e37fa7e5a0 | ||
|
|
6989c7d2fd | ||
|
|
b079b27875 | ||
|
|
166b1a7c6b | ||
|
|
3c895310f4 | ||
|
|
2d3a0cc917 | ||
|
|
ae9e80d6a8 | ||
|
|
f58ffd884b | ||
|
|
9f0abd0bc6 | ||
|
|
afb1b3f16f | ||
|
|
3bedfe75a8 | ||
|
|
93f87f0922 | ||
|
|
76ce8b0876 | ||
|
|
872cee2228 | ||
|
|
fcebbb4856 | ||
|
|
99b7e3126b | ||
|
|
1b02f58247 | ||
|
|
8d8dafade3 | ||
|
|
687b3be784 | ||
|
|
ee3150fc6b | ||
|
|
4922be1422 | ||
|
|
515eb28d4d | ||
|
|
062e65732a | ||
|
|
f186c8cb5f | ||
|
|
c40a73726e | ||
|
|
193587dcfb | ||
|
|
e8d453e2d4 | ||
|
|
3f91ebea5f | ||
|
|
0c4d0cb5c5 | ||
|
|
7f118635b4 | ||
|
|
7efa48b3d7 | ||
|
|
0c199431a9 | ||
|
|
000c482f1b | ||
|
|
4fffcab8aa | ||
|
|
c919648412 | ||
|
|
7eb849affb | ||
|
|
6b57d4a2f7 | ||
|
|
579ca28d6d | ||
|
|
21b52e0b80 | ||
|
|
679921dc8e | ||
|
|
7bd5604607 | ||
|
|
259ea39d55 | ||
|
|
bb83157cbe | ||
|
|
f637ddf8ca | ||
|
|
ca7af014ae | ||
|
|
08b92e1f3d | ||
|
|
a429487894 | ||
|
|
d4e4afb97d | ||
|
|
12f2dc8795 | ||
|
|
dad207912e | ||
|
|
ec76ea307f | ||
|
|
6c9322624d | ||
|
|
499909e09e | ||
|
|
8ae41c0b6a | ||
|
|
baad4742ef | ||
|
|
b6dbc3356d | ||
|
|
a8773a9582 | ||
|
|
2e078294c9 | ||
|
|
efbb78ad7f | ||
|
|
1d001407a0 | ||
|
|
8d41180f4c | ||
|
|
0b994de531 | ||
|
|
5a07ac38da | ||
|
|
caa198964c | ||
|
|
163f483a56 | ||
|
|
c7ded66057 | ||
|
|
e2ce0809da | ||
|
|
8c80e99d3b | ||
|
|
bea85d0f62 | ||
|
|
f87119e31a | ||
|
|
3f2ac6ab76 | ||
|
|
6a5b3a89d9 | ||
|
|
b97c9ee3c9 | ||
|
|
48b0c60cf1 | ||
|
|
f9fbff3fa5 | ||
|
|
9b31e193ee | ||
|
|
cdac5fbf52 | ||
|
|
20d12c0498 | ||
|
|
aa7e010342 | ||
|
|
fec57ecf59 | ||
|
|
74d667b942 | ||
|
|
1c52d533d4 | ||
|
|
9a04535e6b | ||
|
|
c26fdb5dad | ||
|
|
f3d46355af | ||
|
|
db35b6f4e8 | ||
|
|
44621e4f37 | ||
|
|
690d4b8f50 | ||
|
|
a99e290bc5 | ||
|
|
5b0f124307 | ||
|
|
2b5291900d | ||
|
|
cc9d10b12b | ||
|
|
19209d16aa | ||
|
|
5ee924a770 | ||
|
|
a2257999a7 | ||
|
|
d6337ec472 | ||
|
|
d3e87a3d28 | ||
|
|
05f1a6b7ea | ||
|
|
d435d122eb | ||
|
|
dc364981c8 | ||
|
|
362c899632 | ||
|
|
dd8478fe0a | ||
|
|
a80ed6998e | ||
|
|
97d614659a | ||
|
|
c7540ba87b | ||
|
|
3b11f010b5 | ||
|
|
06e282102c | ||
|
|
0b0d2bcdfc | ||
|
|
607123e67a | ||
|
|
3451deee03 | ||
|
|
67e4d24c11 | ||
|
|
2d995d0935 | ||
|
|
0e3c6ac275 | ||
|
|
3b34b1c2d9 | ||
|
|
549f9288a1 | ||
|
|
ae3151d3a7 | ||
|
|
86ba16fbb8 | ||
|
|
f07428a0df | ||
|
|
cb74a2c6ae | ||
|
|
0ab59033b5 | ||
|
|
97b163bc95 | ||
|
|
09f2e89bc4 | ||
|
|
13f3a5e172 | ||
|
|
3066327b0e | ||
|
|
3bcd8bf2d5 | ||
|
|
52d7650d61 | ||
|
|
b0c50bd817 | ||
|
|
aaa38689b3 | ||
|
|
3d95d6b8c9 | ||
|
|
bf62b52183 | ||
|
|
cff710ee52 | ||
|
|
0961d13ac2 | ||
|
|
5003f36337 | ||
|
|
e976f39d2b | ||
|
|
2854a1c8c0 | ||
|
|
c34889ced9 | ||
|
|
5eeaf6853e | ||
|
|
a569a2c2c1 | ||
|
|
08b6141d06 | ||
|
|
356128fbf5 | ||
|
|
6cbc2736d8 | ||
|
|
a1ac2d512b | ||
|
|
2db600b8d7 | ||
|
|
c3fc9879e0 | ||
|
|
5df3dbde1a | ||
|
|
126d6f7f60 | ||
|
|
417571ecd6 | ||
|
|
3d726fe7b0 | ||
|
|
6fa5668cbc | ||
|
|
c6ba21ad4c | ||
|
|
07b8d3d157 | ||
|
|
be3bad7b90 | ||
|
|
d6bd19438c | ||
|
|
2f53c7924d | ||
|
|
0f29506dda | ||
|
|
08d46bbbe3 | ||
|
|
f420c91909 | ||
|
|
db94db2957 | ||
|
|
6c00b8a853 | ||
|
|
c87dcf8aac | ||
|
|
38f20d7eba | ||
|
|
0e1dbc9624 | ||
|
|
19b97f63e5 | ||
|
|
0b90b7ea79 | ||
|
|
fa4258f75c | ||
|
|
2b652cac1f | ||
|
|
583546a7ca | ||
|
|
6c40610d34 | ||
|
|
a6f527bd51 | ||
|
|
f1aec05835 | ||
|
|
56672a862f | ||
|
|
4860ea1b4e | ||
|
|
b5e5fb2bde | ||
|
|
53dcd8b7b2 | ||
|
|
cf82b4899a | ||
|
|
e8e2aab8e3 | ||
|
|
554b21241b | ||
|
|
8d1b523b94 | ||
|
|
d6068395fa | ||
|
|
31c59467db | ||
|
|
4836e5c239 | ||
|
|
54c5a7dcb3 | ||
|
|
0ff4de1e10 | ||
|
|
d4287558f9 | ||
|
|
c91368229a | ||
|
|
da496975bc | ||
|
|
324205ed37 | ||
|
|
aaafb0f465 | ||
|
|
950989b139 | ||
|
|
7618fc97d2 | ||
|
|
498027301e | ||
|
|
f01d224bdf | ||
|
|
35f4be1abc | ||
|
|
08355ff8af | ||
|
|
c9a8f7bd82 | ||
|
|
f2ebfaba3e | ||
|
|
7ad20ccff6 | ||
|
|
67f4c78d61 | ||
|
|
1d4d5b4c1f | ||
|
|
02cf984711 | ||
|
|
2fa37a9732 | ||
|
|
ef86f44215 | ||
|
|
51db8346d3 | ||
|
|
315803dde2 | ||
|
|
e0c330fb29 | ||
|
|
f8280552a0 | ||
|
|
61316c40e5 | ||
|
|
4adfc4353b | ||
|
|
e57fabbd1d | ||
|
|
7d9a7eafc6 | ||
|
|
6ee4e6cd8e | ||
|
|
97b727dcc0 | ||
|
|
2cbee1bf82 | ||
|
|
81525fa61b | ||
|
|
30b4628593 | ||
|
|
87bb092c9d | ||
|
|
5e72cc61b6 | ||
|
|
02f376b6d3 | ||
|
|
db1df07337 | ||
|
|
10f2bc3df5 | ||
|
|
ceb2e0cfb3 | ||
|
|
3e7b1cdc15 | ||
|
|
5e981a579b | ||
|
|
234b15765c | ||
|
|
2b03973cf0 | ||
|
|
53d81aebed | ||
|
|
462aaad9c0 | ||
|
|
afac41d3e6 | ||
|
|
4f72a61ea6 | ||
|
|
f54d08a628 | ||
|
|
bc1ae8b496 | ||
|
|
5b42a4b36e | ||
|
|
98ee88c1bb | ||
|
|
7c89ff5c1b | ||
|
|
bd8abbbdbd | ||
|
|
9249dfee4c | ||
|
|
1ac945ad66 | ||
|
|
e270d2534f | ||
|
|
c2b038c1c0 | ||
|
|
d5862f476b | ||
|
|
02b5179eb3 | ||
|
|
100fd2c9f0 | ||
|
|
a2f55b9838 | ||
|
|
4fef4a8d33 | ||
|
|
933f799952 | ||
|
|
3018f8c521 | ||
|
|
826fccbc94 | ||
|
|
54453ee9e5 | ||
|
|
be0b5bb0d1 | ||
|
|
cc957cc3e8 | ||
|
|
2b274b706e | ||
|
|
dd5fd72bd2 | ||
|
|
3ab1f9b5a3 | ||
|
|
9d4655cc00 | ||
|
|
e512fef78c | ||
|
|
3320de07f2 | ||
|
|
448e82108d | ||
|
|
68482afa5c | ||
|
|
be93e77b2f | ||
|
|
bfeded207a | ||
|
|
5aed2b6baf | ||
|
|
a5971b0c69 | ||
|
|
00b5aba88a | ||
|
|
6d93ccc0d0 | ||
|
|
9c0edfdb9d | ||
|
|
69f5de6478 | ||
|
|
b40e2e0a6f | ||
|
|
4630a8d18d | ||
|
|
d73130ebac | ||
|
|
79e40a667b | ||
|
|
13016c7476 | ||
|
|
2c904641a5 | ||
|
|
667eb3035b | ||
|
|
1303163aee | ||
|
|
13f2ee2ae8 | ||
|
|
14ecfd2cc8 | ||
|
|
1b46c39a27 | ||
|
|
a91eb9435b | ||
|
|
5d19096e0c | ||
|
|
b5d2285b99 | ||
|
|
3f79189410 | ||
|
|
fece24a50a | ||
|
|
1940099d3c | ||
|
|
7d77da8339 | ||
|
|
240e0780a0 | ||
|
|
e43366f422 | ||
|
|
3e38ef959b | ||
|
|
c7af5bdce9 | ||
|
|
9e2af21d5e | ||
|
|
3f302ee4a3 | ||
|
|
3aa4d4c36c | ||
|
|
53ef0f3f2d | ||
|
|
81866cb6d3 | ||
|
|
c6dbb8d7c8 | ||
|
|
bee20a5478 | ||
|
|
f4088e0b38 | ||
|
|
b43e32169b | ||
|
|
d3d46aa023 | ||
|
|
4d99541f7c | ||
|
|
55cc3dd90e | ||
|
|
089b67c40e | ||
|
|
0822789002 | ||
|
|
9ca0f4a4fa | ||
|
|
ffb2ec9bd1 | ||
|
|
0e1a0b4798 | ||
|
|
2bcb56d249 | ||
|
|
467ae5c8fa | ||
|
|
8ccbe9c069 | ||
|
|
a3bf50e15e | ||
|
|
85f98f7405 | ||
|
|
9d44a73d02 | ||
|
|
ac946fd014 | ||
|
|
8e9d537882 | ||
|
|
3680d0c65d | ||
|
|
774b9cc368 | ||
|
|
8130d28442 | ||
|
|
00e3b06004 | ||
|
|
9cac38d5da | ||
|
|
3014ba8eec | ||
|
|
81122c9865 | ||
|
|
823f0b8db5 | ||
|
|
b8a0077b1d | ||
|
|
af1b1c0edb | ||
|
|
62108e3dac | ||
|
|
dd4c3f152a | ||
|
|
0c9fa8b51b | ||
|
|
0a511e4f8a | ||
|
|
aa2bf4fe22 | ||
|
|
524c2b8203 | ||
|
|
e82f561c03 | ||
|
|
578d9c6785 | ||
|
|
d348fe9012 | ||
|
|
c7efad2197 | ||
|
|
7271b7a5f0 | ||
|
|
adda8707ba | ||
|
|
522536cfe0 | ||
|
|
640d0082da | ||
|
|
f557b53ce2 | ||
|
|
f5bd7f113f | ||
|
|
1277da7e92 | ||
|
|
8b1978fb26 | ||
|
|
f334122e41 | ||
|
|
812e8cca9a | ||
|
|
269cbe839b | ||
|
|
63bc04e800 | ||
|
|
2a3bcc2ecb | ||
|
|
7eb776bc3f | ||
|
|
5babab85c8 | ||
|
|
56981a5333 | ||
|
|
8debd01a36 | ||
|
|
54cd4723ba | ||
|
|
51a37cae3d | ||
|
|
c9f8b04a12 | ||
|
|
3c24e76eb4 | ||
|
|
11e970ee8a | ||
|
|
6dc9f8ea2b | ||
|
|
3d7367aa04 | ||
|
|
157b1ca0b4 | ||
|
|
2bcf5b2fc5 | ||
|
|
8be95262d4 | ||
|
|
39bc4d7151 | ||
|
|
3cbdae6e5c | ||
|
|
f08e58a301 | ||
|
|
edcf0661a6 | ||
|
|
a49270630c | ||
|
|
6d14c5c555 | ||
|
|
f703517f70 | ||
|
|
a0662d5323 | ||
|
|
6c1ca3036b | ||
|
|
cbd1cf7c25 | ||
|
|
6ed80a9b92 | ||
|
|
a55225b5e8 | ||
|
|
42fa5c2ee7 | ||
|
|
b81c3ba614 | ||
|
|
8f34b241d4 | ||
|
|
2d0998a995 | ||
|
|
b0d6ce61b0 | ||
|
|
766840de68 | ||
|
|
9defa45428 | ||
|
|
791f2e0b34 | ||
|
|
52bcb8dfb6 | ||
|
|
9241a7c35d | ||
|
|
1f90f13b81 | ||
|
|
dda92477cf | ||
|
|
0a522863dc | ||
|
|
07455e5821 | ||
|
|
e8a974813d | ||
|
|
1b9aae0137 | ||
|
|
50da387936 | ||
|
|
30b86ea781 | ||
|
|
489869ee42 | ||
|
|
a186d44d8f | ||
|
|
316b2a1b1c | ||
|
|
574f75b293 | ||
|
|
a1625f7125 | ||
|
|
252ae7455a | ||
|
|
63379d9b24 | ||
|
|
d73dbdeee0 | ||
|
|
d812f26e81 | ||
|
|
72065c0ee2 | ||
|
|
4ba3152a99 | ||
|
|
07caee1d12 | ||
|
|
d4f48cdc21 | ||
|
|
4c3904760c | ||
|
|
dc0cc3af65 | ||
|
|
8ad2c2a59e | ||
|
|
27031c96b5 | ||
|
|
e5a365ba41 | ||
|
|
b1ca28fbb5 | ||
|
|
fc0b118188 | ||
|
|
1b7bfb42fc | ||
|
|
a207b93d0d | ||
|
|
ea65204eaa | ||
|
|
b1d588b1f2 | ||
|
|
4351e5a642 | ||
|
|
95a6bab8b5 | ||
|
|
f35289624c | ||
|
|
c82433e6b4 | ||
|
|
47c322cb31 | ||
|
|
2e84852519 | ||
|
|
88f1237990 | ||
|
|
da746d15a0 | ||
|
|
4740a8b520 | ||
|
|
1b519269d8 | ||
|
|
521b6ab851 | ||
|
|
5ffaf1aead | ||
|
|
9e328551e4 | ||
|
|
b704406164 | ||
|
|
44eaffd110 | ||
|
|
5c9fe40702 | ||
|
|
cb964b5888 | ||
|
|
fe7c4f96aa | ||
|
|
81cbc7b87c | ||
|
|
83909a07fa | ||
|
|
8fa45749a9 | ||
|
|
cd99927881 | ||
|
|
910ea4caec | ||
|
|
8bbb485d5b | ||
|
|
0bff263c4b | ||
|
|
b2ec77668e | ||
|
|
38f85d3cc8 | ||
|
|
f8302ab65a | ||
|
|
83002d09a4 | ||
|
|
e632cf1c42 | ||
|
|
a567178987 | ||
|
|
640557df4f | ||
|
|
13c47639da | ||
|
|
9b7227a88b | ||
|
|
74b0535b31 | ||
|
|
aabc912862 | ||
|
|
cbd7799b44 | ||
|
|
02d6567347 | ||
|
|
98a8c4752b | ||
|
|
6f8767d1fc | ||
|
|
b2debb32d1 | ||
|
|
bc787efc86 | ||
|
|
098f3f6e4c | ||
|
|
e0d46c3942 | ||
|
|
e8c7f728a2 | ||
|
|
5a2bed29aa | ||
|
|
387ffbb0fc | ||
|
|
8fbcd0c34d | ||
|
|
d2d4f6186f | ||
|
|
97df37536c | ||
|
|
d5cd02cab3 | ||
|
|
373b9c6a97 | ||
|
|
d831710b0a | ||
|
|
009726c62d | ||
|
|
d5316b2c4d | ||
|
|
69c07a41e9 | ||
|
|
7c4bedf371 | ||
|
|
64afd62a1f | ||
|
|
7018ed28fb | ||
|
|
4318468957 | ||
|
|
7213e62937 | ||
|
|
1af3fc1c96 | ||
|
|
219ea98f33 | ||
|
|
1e11f8032a | ||
|
|
f6cbc36112 | ||
|
|
a1a7ca8a0a | ||
|
|
93bc54e275 | ||
|
|
52758fa66e | ||
|
|
44cd109ba3 | ||
|
|
fa43ff1365 | ||
|
|
482168f98e | ||
|
|
bd15162fb7 | ||
|
|
f9b9c7136e | ||
|
|
cc980edc66 | ||
|
|
84ec26f648 | ||
|
|
7fd094ba39 | ||
|
|
fcfe5da506 | ||
|
|
68ef489d8c | ||
|
|
1e4bdb367e | ||
|
|
21ff1d7482 | ||
|
|
d3ee55a971 | ||
|
|
669b1d9a63 | ||
|
|
3a967c5985 | ||
|
|
29531a361c | ||
|
|
92f5df4704 | ||
|
|
c40cf2e7e8 | ||
|
|
2e8789de3b | ||
|
|
7bf391e772 | ||
|
|
b7827f3eea | ||
|
|
fbb84af955 | ||
|
|
8c101a1bbf | ||
|
|
d954eb63ef | ||
|
|
ee216dbf64 | ||
|
|
1b14161535 | ||
|
|
54675117de | ||
|
|
bcf854604b | ||
|
|
30d5b46daf | ||
|
|
f265d9174a | ||
|
|
45ec489080 | ||
|
|
970e0e2d04 | ||
|
|
93fe613a9a | ||
|
|
9055865e1c | ||
|
|
704f2c176d | ||
|
|
f9b6aca8e8 | ||
|
|
d538134bb9 | ||
|
|
d084b5a03c | ||
|
|
6e38050ac4 | ||
|
|
a6ab0ff2aa | ||
|
|
f3c87ef313 | ||
|
|
1bce924d83 | ||
|
|
09a2136f02 | ||
|
|
f571e8ac6e | ||
|
|
5c7331d0a4 | ||
|
|
27bf2e642b | ||
|
|
187ea86c24 | ||
|
|
d4ca903a07 | ||
|
|
48639adc42 | ||
|
|
0f8bbdc9f2 | ||
|
|
509412dee6 | ||
|
|
fb9f814b00 | ||
|
|
44a95c4888 | ||
|
|
b4f88196dc | ||
|
|
0f3400a6b7 | ||
|
|
78e748548c | ||
|
|
a55bbc5e8c | ||
|
|
199cddfbdb | ||
|
|
8dad478a19 | ||
|
|
c0ca602017 | ||
|
|
31208c2af1 | ||
|
|
3471b9853e | ||
|
|
11f57b02e6 | ||
|
|
6765d7d89f | ||
|
|
86a99e2337 | ||
|
|
250aa17e63 | ||
|
|
3470d38d7c | ||
|
|
2942d20ac3 | ||
|
|
e6959e75f9 | ||
|
|
d32799b2dc | ||
|
|
1e4f70747b | ||
|
|
ff62fbbcf4 | ||
|
|
6ee3bc099d | ||
|
|
69ec362a8d | ||
|
|
13d44ee3e8 | ||
|
|
41d00543d0 | ||
|
|
fc9bffddbd | ||
|
|
f890e590e1 | ||
|
|
64d573e28e | ||
|
|
2aec982577 | ||
|
|
b2781a1ea6 | ||
|
|
b66d5daad0 | ||
|
|
04cdc75841 | ||
|
|
6ff07b99dc | ||
|
|
bb7bb40e76 | ||
|
|
1586860e15 | ||
|
|
a4055364e4 | ||
|
|
99dac8f6fd | ||
|
|
71da6e4528 | ||
|
|
5fb910b886 | ||
|
|
5c113284e2 | ||
|
|
fb826ec838 | ||
|
|
b2cb3bcf1d | ||
|
|
5198cc17d3 | ||
|
|
1821f90664 | ||
|
|
261ecfcb11 | ||
|
|
a66a8982ee | ||
|
|
6582bce0d3 | ||
|
|
0a83a1f168 | ||
|
|
db91ca82c1 | ||
|
|
e97d3172eb | ||
|
|
cb7fbc16b0 | ||
|
|
7c838bf54e | ||
|
|
c6c639f16f | ||
|
|
4a5c5143b3 | ||
|
|
cb5968bc5f | ||
|
|
c02afbb4f9 | ||
|
|
693b25efc5 | ||
|
|
b647bc9b41 | ||
|
|
6eddaeda61 | ||
|
|
c36b90db0f | ||
|
|
349bfa9139 | ||
|
|
ddf3959d4d | ||
|
|
b0f75678ee | ||
|
|
b5f88c199c | ||
|
|
0a0f7d7365 | ||
|
|
a0586457da | ||
|
|
6d1ff98bda | ||
|
|
288d1f7e5a | ||
|
|
065324d256 | ||
|
|
38c28bccdb | ||
|
|
69f7c3417e | ||
|
|
e8b0178ae4 | ||
|
|
806f57c627 | ||
|
|
9eeebf93fa | ||
|
|
e4c7844cae | ||
|
|
c1ccf02ff9 | ||
|
|
6ebfa16740 | ||
|
|
6533aa2826 | ||
|
|
43cfdb8727 | ||
|
|
ece1a51530 | ||
|
|
b31c5be70e | ||
|
|
1d4a407161 | ||
|
|
d84d047470 | ||
|
|
9f5678c711 | ||
|
|
42a0659012 | ||
|
|
819ac84c2a | ||
|
|
6386f0f9aa | ||
|
|
fe90f3703e | ||
|
|
9aaf17d478 | ||
|
|
0e956a605f | ||
|
|
1f908f9040 | ||
|
|
32210d89f8 | ||
|
|
b51ef059f5 | ||
|
|
18a77c995f | ||
|
|
9f36234c52 | ||
|
|
0b74d9e998 | ||
|
|
54d545094f | ||
|
|
c239c476af | ||
|
|
a382a0cd44 | ||
|
|
0fee59a6ed | ||
|
|
e18226d108 | ||
|
|
b079952491 | ||
|
|
d2da71c22a | ||
|
|
9eb2a6a535 | ||
|
|
dd5ef7ec72 | ||
|
|
c2cbcd3727 | ||
|
|
5c7baf9e05 | ||
|
|
e5f5e18ecc | ||
|
|
dae30037b6 | ||
|
|
30eba3bfae | ||
|
|
77c0486f8c | ||
|
|
e00475520a | ||
|
|
bf90a6247e | ||
|
|
3185cc041a | ||
|
|
f64b9084f5 | ||
|
|
dc09561f30 | ||
|
|
e154cbe1ba | ||
|
|
1f9ac49e27 | ||
|
|
a7de923cea | ||
|
|
a75430106e | ||
|
|
bc816100a0 | ||
|
|
33de209497 | ||
|
|
8401e25504 | ||
|
|
db14c695e6 | ||
|
|
7a61b2ec80 | ||
|
|
1e16e58f37 | ||
|
|
e84ca44178 | ||
|
|
644c03503b | ||
|
|
d88288302a | ||
|
|
42e0797b5b | ||
|
|
8826d41922 | ||
|
|
26d2d6f403 | ||
|
|
438386de5d | ||
|
|
99197396f1 | ||
|
|
3770463499 | ||
|
|
d3979a5a5a | ||
|
|
e5bba73ea8 | ||
|
|
cd925d1896 | ||
|
|
82fe6f6fa7 | ||
|
|
c05cf29a37 | ||
|
|
160f491cc5 | ||
|
|
d652013572 | ||
|
|
c970503f61 | ||
|
|
5218f4f182 | ||
|
|
9230a77f96 | ||
|
|
f8cc78eca5 | ||
|
|
a9f9af3cb8 | ||
|
|
ec71621d93 | ||
|
|
52376993df | ||
|
|
74a5253c69 | ||
|
|
2aebc023d1 | ||
|
|
8dfd453381 | ||
|
|
899cb9d4cf | ||
|
|
e34021c0be | ||
|
|
041d5da13b | ||
|
|
d421848795 | ||
|
|
96185e9c60 | ||
|
|
5bd8ef2e5d | ||
|
|
3dae7e9523 | ||
|
|
7d4660173e | ||
|
|
612c6a331b | ||
|
|
0c852a145e | ||
|
|
ed2d3a27e7 | ||
|
|
de162817af | ||
|
|
fd1acd6533 | ||
|
|
7282f61133 | ||
|
|
0687d9ed98 | ||
|
|
e45a3ebdb4 | ||
|
|
b72f9f054d | ||
|
|
92b9fb60e9 | ||
|
|
08951ab515 | ||
|
|
c2d2bd0ea1 | ||
|
|
ff6204c98e | ||
|
|
c08831ca13 | ||
|
|
c8ef72e4d2 | ||
|
|
b1bd52423a | ||
|
|
4b980b8076 | ||
|
|
63baa20403 | ||
|
|
612aca217c | ||
|
|
92b56c99a3 | ||
|
|
349b18d63a | ||
|
|
11d331c051 | ||
|
|
63851b16af | ||
|
|
4384eed09f | ||
|
|
e746805eaa | ||
|
|
6c480178fe | ||
|
|
7e94cc7ff8 | ||
|
|
db20eeb555 | ||
|
|
9794f12a9b | ||
|
|
9af88076e6 | ||
|
|
290ae85128 | ||
|
|
5c78760649 | ||
|
|
3cb8365ef3 | ||
|
|
38e95a7f07 | ||
|
|
6d392b1c91 | ||
|
|
a8f7028c22 | ||
|
|
35c7366b96 | ||
|
|
137bd43821 | ||
|
|
08c9a0630d | ||
|
|
abdc9f75cc | ||
|
|
ecaae1b934 | ||
|
|
fc06f8c88e | ||
|
|
0fc62f07cc | ||
|
|
4afb12669a | ||
|
|
030864b72b | ||
|
|
0bf6e39c66 | ||
|
|
0d6613b998 | ||
|
|
99875ff746 | ||
|
|
05bb0fcf43 | ||
|
|
bce60758e9 | ||
|
|
7b85e78636 | ||
|
|
4fa6ef828c | ||
|
|
08ca3431ac | ||
|
|
cfcc21b1cb | ||
|
|
4ea54ef5ce | ||
|
|
fc65920462 | ||
|
|
88a7ff62af | ||
|
|
1c75ae08bc | ||
|
|
5ea63534f7 | ||
|
|
95805169dc | ||
|
|
bcd018d8de | ||
|
|
34627f5e60 | ||
|
|
0ae1692f99 | ||
|
|
6becbee27a | ||
|
|
78633b06de | ||
|
|
78bf265d7a | ||
|
|
1690a25262 | ||
|
|
f76f284ce2 | ||
|
|
5080b754d4 | ||
|
|
bdb97182e4 | ||
|
|
c668ed8a2b | ||
|
|
10a1350bb3 | ||
|
|
c10fb2916f | ||
|
|
91185abb4c | ||
|
|
e402b06c6c | ||
|
|
6a09adf11c | ||
|
|
ba7ba751fd | ||
|
|
ba3c02c912 | ||
|
|
6f6bd256b5 | ||
|
|
c8d1780ee8 | ||
|
|
31e904c21a | ||
|
|
6773488644 | ||
|
|
84b0d52510 | ||
|
|
db9aa4bc38 | ||
|
|
04e1534001 | ||
|
|
74d4928fb0 | ||
|
|
d31d422eb0 | ||
|
|
eb5b62b670 | ||
|
|
53ef4fee1e | ||
|
|
b3cdc4f5fc | ||
|
|
63abb61248 | ||
|
|
59e16866fb | ||
|
|
9fc36bd6fa | ||
|
|
4051fae33b | ||
|
|
b014dadfe3 | ||
|
|
900b084156 | ||
|
|
fa96c94085 | ||
|
|
bd1d287c87 | ||
|
|
b74f7e4eac | ||
|
|
7a57132c1c | ||
|
|
46c2367e50 | ||
|
|
7378f85297 |
5
.coveragerc
Normal file
5
.coveragerc
Normal file
@@ -0,0 +1,5 @@
|
||||
[report]
|
||||
omit =
|
||||
*/settings.py
|
||||
*/python?.?/*
|
||||
*/site-packages/nose/*
|
||||
9
.env.example
Normal file
9
.env.example
Normal file
@@ -0,0 +1,9 @@
|
||||
REDASH_CONNECTION_ADAPTER=pg
|
||||
REDASH_CONNECTION_STRING="dbname=data"
|
||||
REDASH_STATIC_ASSETS_PATH=../rd_ui/app/
|
||||
REDASH_GOOGLE_APPS_DOMAIN=
|
||||
REDASH_ADMINS=
|
||||
REDASH_WORKERS_COUNT=2
|
||||
REDASH_COOKIE_SECRET=
|
||||
REDASH_DATABASE_URL='postgresql://rd'
|
||||
REDASH_LOG_LEVEL = "INFO"
|
||||
14
.gitignore
vendored
14
.gitignore
vendored
@@ -1,10 +1,20 @@
|
||||
.coveralls.yml
|
||||
.idea
|
||||
*.pyc
|
||||
rd_service/settings.py
|
||||
.coverage
|
||||
rd_ui/dist
|
||||
.DS_Store
|
||||
celerybeat-schedule*
|
||||
.#*
|
||||
\#*#
|
||||
*~
|
||||
|
||||
# Vagrant related
|
||||
.vagrant
|
||||
Berksfile.lock
|
||||
rd_service/dump.rdb
|
||||
redash/dump.rdb
|
||||
.env
|
||||
.ruby-version
|
||||
venv
|
||||
|
||||
dump.rdb
|
||||
|
||||
2
.landscape.yaml
Normal file
2
.landscape.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
ignore-paths:
|
||||
- migrations
|
||||
@@ -1,3 +0,0 @@
|
||||
cookbook 'apt'
|
||||
cookbook 'postgresql'
|
||||
cookbook 'redash', git: 'git@github.com:EverythingMe/chef-redash.git'
|
||||
16
Makefile
16
Makefile
@@ -1,16 +1,22 @@
|
||||
NAME=redash
|
||||
VERSION=0.2
|
||||
FULL_VERSION=$(VERSION).$(CIRCLE_BUILD_NUM)
|
||||
FILENAME=$(CIRCLE_ARTIFACTS)/$(NAME).$(FULL_VERSION).tar.gz
|
||||
VERSION=`python ./manage.py version`
|
||||
FULL_VERSION=$(VERSION)+b$(CIRCLE_BUILD_NUM)
|
||||
# VERSION gets evaluated every time it's referenced, therefore we need to use VERSION here instead of FULL_VERSION.
|
||||
FILENAME=$(CIRCLE_ARTIFACTS)/$(NAME).$(VERSION).tar.gz
|
||||
|
||||
deps:
|
||||
cd rd_ui && npm install
|
||||
cd rd_ui && npm install grunt-cli bower
|
||||
cd rd_ui && npm install -g bower grunt-cli
|
||||
cd rd_ui && bower install
|
||||
cd rd_ui && grunt build
|
||||
|
||||
pack:
|
||||
sed -ri "s/^__version__ = '([0-9.]*)'/__version__ = '$(FULL_VERSION)'/" redash/__init__.py
|
||||
tar -zcv -f $(FILENAME) --exclude=".git*" --exclude="*.pyc" --exclude="*.pyo" --exclude="venv" --exclude="rd_ui/node_modules" --exclude="rd_ui/dist/bower_components" --exclude="rd_ui/app" *
|
||||
|
||||
upload:
|
||||
python bin/upload_version.py $(FULL_VERSION) $(FILENAME)
|
||||
python bin/upload_version.py $(VERSION) $(FILENAME)
|
||||
|
||||
test:
|
||||
nosetests --with-coverage --cover-package=redash tests/*.py
|
||||
cd rd_ui && grunt test
|
||||
|
||||
2
Procfile.dev
Normal file
2
Procfile.dev
Normal file
@@ -0,0 +1,2 @@
|
||||
web: ./manage.py runserver -p $PORT --host 0.0.0.0
|
||||
worker: ./bin/run celery worker --app=redash.worker --beat -Qqueries,celery,scheduled_queries
|
||||
2
Procfile.heroku
Normal file
2
Procfile.heroku
Normal file
@@ -0,0 +1,2 @@
|
||||
web: ./manage.py runserver -p $PORT --host 0.0.0.0 -d -r
|
||||
worker: ./bin/run celery worker --app=redash.worker --beat -Qqueries,celery,scheduled_queries
|
||||
108
README.md
108
README.md
@@ -1,112 +1,46 @@
|
||||
# [_re:dash_](https://github.com/everythingme/redash)
|
||||
<p align="center">
|
||||
<img title="re:dash" src='https://raw.githubusercontent.com/EverythingMe/redash/screenshots/redash_logo.png' />
|
||||
|
||||
</p>
|
||||
<p align="center">
|
||||
<img title="Build Status" src='https://circleci.com/gh/EverythingMe/redash.png?circle-token=8a695aa5ec2cbfa89b48c275aea298318016f040'/>
|
||||
</p>
|
||||
|
||||
**_re:dash_** is our take on freeing the data within our company in a way that will better fit our culture and usage patterns.
|
||||
|
||||
Prior to **_re:dash_**, we tried to use tranditional BI suites and discovered a set of bloated, technically challenged and slow tools/flows. What we were looking for was a more hacker'ish way to look at data, so we built one.
|
||||
Prior to **_re:dash_**, we tried to use traditional BI suites and discovered a set of bloated, technically challenged and slow tools/flows. What we were looking for was a more hacker'ish way to look at data, so we built one.
|
||||
|
||||
**_re:dash_** was built to allow fast and easy access to billions of records, that we process and collect using Amazon Redshift ("petabyte scale data warehouse" that "speaks" PostgreSQL).
|
||||
Today **_re:dash_** has support for querying multiple databases, including: Redshift, Google BigQuery, PostgreSQL, MySQL, Graphite and custom scripts.
|
||||
|
||||
**_re:dash_** consists of two parts:
|
||||
|
||||
1. **Query Editor**: think of [JS Fiddle](http://jsfiddle.net) for SQL queries. It's your way to share data in the organization in an open way, by sharing both the dataset and the query that generated it. This way everyone can peer review not only the resulting dataset but also the process that generated it. Also it's possible to fork it and generate new datasets and reach new insights.
|
||||
2. **Dashboards/Visualizations**: once you have a dataset, you can create different visualizations out of it, and then combine several visualizations into a single dashboard. Currently it supports bar charts, pivot table and cohorts.
|
||||
1. **Query Editor**: think of [JS Fiddle](http://jsfiddle.net) for SQL queries. It's your way to share data in the organization in an open way, by sharing both the dataset and the query that generated it. This way everyone can peer review not only the resulting dataset but also the process that generated it. Also it's possible to fork it and generate new datasets and reach new insights.
|
||||
2. **Dashboards/Visualizations**: once you have a dataset, you can create different visualizations out of it, and then combine several visualizations into a single dashboard. Currently it supports charts, pivot table and cohorts.
|
||||
|
||||
This is the first release, which is more than usable but still has its rough edges and way to go to fulfill its full potential. The Query Editor part is quite solid, but the visualizations need more work to enrich them and to make them more user friendly.
|
||||
**_re:dash_** is a work in progress and has its rough edges and way to go to fulfill its full potential. The Query Editor part is quite solid, but the visualizations need more work to enrich them and to make them more user friendly.
|
||||
|
||||
## Demo
|
||||
|
||||

|
||||
|
||||
You can try out the demo instance: http://rd-demo.herokuapp.com/ (login with any Google account).
|
||||
You can try out the demo instance: http://demo.redash.io/ (login with any Google account).
|
||||
|
||||
## Getting Started
|
||||
|
||||
* [Setting up re:dash instance](https://github.com/EverythingMe/redash/wiki/Setting-up-re:dash-instance) (includes links to ready made AWS/GCE images).
|
||||
* Additional documentation in the [Wiki](https://github.com/everythingme/redash/wiki).
|
||||
|
||||
Due to Heroku dev plan limits, it has a small database of flights (see schema [here](http://rd-demo.herokuapp.com/dashboard/schema)). Also due to another Heroku limitation, it is running with the regular user, hence you can DELETE or INSERT data/tables. Please be nice and don't do this.
|
||||
|
||||
## Getting help
|
||||
|
||||
* [Google Group (mailing list)](https://groups.google.com/forum/#!forum/redash-users): the best place to get updates about new releases or ask general questions.
|
||||
* #redash IRC channel on [Freenode](http://www.freenode.net/).
|
||||
|
||||
## Technology
|
||||
|
||||
* [AngularJS](http://angularjs.org/)
|
||||
* [Tornado](http://tornadoweb.org)
|
||||
* [PostgreSQL](http://www.postgresql.org/) / [AWS Redshift](http://aws.amazon.com/redshift/)
|
||||
* [Redis](http://redis.io)
|
||||
|
||||
PostgreSQL is used both as the operatinal database for the system, but also as the data store that is being queried. To be exact, we built this system to use on top of Amazon's Redshift, which supports the PG driver. But it's quite simple to add support for other datastores, and we do plan to do so.
|
||||
|
||||
This is our first large scale AngularJS project, and we learned a lot during the development of it. There are still things we need to iron out, and comments on the way we use AngularJS are more than welcome (and pull requests just as well).
|
||||
|
||||
### HighCharts
|
||||
|
||||
HighCharts is really great, but it's not free for commercial use. Please refer to their [licensing options](http://shop.highsoft.com/highcharts.html), to see what applies for your use.
|
||||
|
||||
It's very likely that in the future we will switch to [D3.js](http://d3js.org/) instead.
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. Clone the repo:
|
||||
```bash
|
||||
git clone git@github.com:EverythingMe/redash.git
|
||||
```
|
||||
2. Create settings file from the example one (& update relevant settings):
|
||||
```bash
|
||||
cp rd_service/settings_example.py rd_service/settings.py
|
||||
```
|
||||
It's highly recommended that the user you use to connect to the data database (the one you query) is read-only.
|
||||
3. Create the operational databases from rd_service/data/tables.sql
|
||||
3. Install `npm` packages (mainly: Bower & Grunt):
|
||||
```bash
|
||||
cd rd_ui
|
||||
npm install
|
||||
```
|
||||
4. Install `bower` packages:
|
||||
```bash
|
||||
bower install
|
||||
```
|
||||
5. Build the UI:
|
||||
```bash
|
||||
grunt build
|
||||
```
|
||||
6. Install PIP packages:
|
||||
```bash
|
||||
pip install -r ../rd_service/requirements.txt
|
||||
```
|
||||
6. Start the API server:
|
||||
```bash
|
||||
cd ../rd_service
|
||||
python server.py
|
||||
```
|
||||
7. Start the workers:
|
||||
```bash
|
||||
python cli.py worker
|
||||
```
|
||||
8. Open `http://localhost:8888/` and query away.
|
||||
* Find us [on gitter](https://gitter.im/EverythingMe/redash#) (chat).
|
||||
* Contact Arik, the maintainer directly: arik@everything.me.
|
||||
|
||||
## Roadmap
|
||||
|
||||
We plan to release new minor version every 2-3 weeks. Of course, if we get additional help from contributors it will help speed things up.
|
||||
|
||||
Below you can see the "big" features of the next 3 releases (for full list, click on the link):
|
||||
|
||||
### [v0.2](https://github.com/EverythingMe/redash/issues?milestone=1&state=open)
|
||||
|
||||
- Ability to generate multiple visualizations for a single query (dataset) in a more flexible way than today. Also easier extensbility points to add additional visualizations.
|
||||
- Dashboard filters: ability to filter/slice the data you see in a single dashboard using filters (date or selectors).
|
||||
- UI Improvements (better notifications & flows, improved queries page)
|
||||
- Comments on queries.
|
||||
|
||||
### [v0.3](https://github.com/EverythingMe/redash/issues?milestone=2&state=open)
|
||||
|
||||
- Support for API access using API keys, instead of Google Login.
|
||||
- Multiple databases support (including other database type than PostgreSQL).
|
||||
- Scheduled reports by email.
|
||||
|
||||
### [v0.4](https://github.com/EverythingMe/redash/issues?milestone=3&state=open)
|
||||
|
||||
- Query versioning.
|
||||
- More "realtime" UI (using websockets).
|
||||
- More visualizations.
|
||||
TBD.
|
||||
|
||||
## Reporting Bugs and Contributing Code
|
||||
|
||||
|
||||
57
Vagrantfile
vendored
57
Vagrantfile
vendored
@@ -2,59 +2,10 @@
|
||||
# vi: set ft=ruby :
|
||||
|
||||
# Vagrantfile API/syntax version. Don't touch unless you know what you're doing!
|
||||
VAGRANTFILE_API_VERSION = '2'
|
||||
|
||||
POSTGRES_PASSWORD = 'securepass'
|
||||
|
||||
# Currently, chef postgress cookbook works with cleartext paswords,
|
||||
# unless the password begins with 'md5'
|
||||
# See https://github.com/hw-cookbooks/postgresql/issues/95
|
||||
require "digest/md5"
|
||||
postgres_password_md5 = 'md5'+Digest::MD5.hexdigest(POSTGRES_PASSWORD+'postgres')
|
||||
|
||||
# After starting the vagrant machine, the application is accessible via the URL
|
||||
# http://localhost:9999
|
||||
HOST_PORT_TO_FORWARD_TO_REDASH = 9999
|
||||
|
||||
# Deploy direcly the code in parent dir; Don't download a release tarball
|
||||
live_testing_deployment = true
|
||||
VAGRANTFILE_API_VERSION = "2"
|
||||
|
||||
Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
|
||||
config.vm.box = 'ubuntu-precise-cloudimg-amd64'
|
||||
config.vm.box_url = 'http://cloud-images.ubuntu.com/vagrant/precise/current/precise-server-cloudimg-amd64-vagrant-disk1.box'
|
||||
|
||||
if config.respond_to? :cache
|
||||
config.cache.auto_detect = true
|
||||
end
|
||||
|
||||
config.berkshelf.enabled = true
|
||||
config.omnibus.chef_version = :latest
|
||||
|
||||
config.vm.network 'forwarded_port', guest: 8888, host: HOST_PORT_TO_FORWARD_TO_REDASH
|
||||
|
||||
if live_testing_deployment
|
||||
config.vm.synced_folder "..", "/opt/redash"
|
||||
end
|
||||
|
||||
config.vm.provision :chef_solo do |chef|
|
||||
# run apt-get update before anything else (specifically postgresql)..
|
||||
chef.add_recipe 'apt'
|
||||
chef.add_recipe 'redash::redis_for_redash'
|
||||
chef.add_recipe 'postgresql::client'
|
||||
chef.add_recipe 'postgresql::server'
|
||||
chef.add_recipe 'redash::redash_pg_schema'
|
||||
chef.add_recipe 'redash::redash'
|
||||
# chef.log_level = :debug
|
||||
chef.json = {
|
||||
'apt' => { 'compiletime' => true },
|
||||
'postgresql' => { 'password' => {'postgres' => postgres_password_md5 } },
|
||||
'redash' => { 'db' => {'host' => 'localhost',
|
||||
'user' => 'postgres',
|
||||
'password' => POSTGRES_PASSWORD },
|
||||
'allow' => {'google_app_domain' => 'gmail.com',
|
||||
'admins' => ['joe@egmail.com','jack@gmail.com']},
|
||||
'install_tarball' => !live_testing_deployment,
|
||||
'user' => live_testing_deployment ? 'vagrant' : 'redash'}
|
||||
}
|
||||
end
|
||||
config.vm.box = "redash/dev"
|
||||
config.vm.synced_folder "./", "/opt/redash/current"
|
||||
config.vm.network "forwarded_port", guest: 5000, host: 9001
|
||||
end
|
||||
|
||||
30
bin/latest_release.py
Executable file
30
bin/latest_release.py
Executable file
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env python
|
||||
import sys
|
||||
import requests
|
||||
|
||||
if __name__ == '__main__':
|
||||
response = requests.get('https://api.github.com/repos/EverythingMe/redash/releases')
|
||||
|
||||
if response.status_code != 200:
|
||||
exit("Failed getting releases (status code: %s)." % response.status_code)
|
||||
|
||||
sorted_releases = sorted(response.json(), key=lambda release: release['id'], reverse=True)
|
||||
|
||||
latest_release = sorted_releases[0]
|
||||
asset_url = latest_release['assets'][0]['url']
|
||||
filename = latest_release['assets'][0]['name']
|
||||
|
||||
wget_command = 'wget --header="Accept: application/octet-stream" %s -O %s' % (asset_url, filename)
|
||||
|
||||
if '--url-only' in sys.argv:
|
||||
print asset_url
|
||||
elif '--wget' in sys.argv:
|
||||
print wget_command
|
||||
else:
|
||||
print "Latest release: %s" % latest_release['tag_name']
|
||||
print latest_release['body']
|
||||
|
||||
print "\nTarball URL: %s" % asset_url
|
||||
print 'wget: %s' % (wget_command)
|
||||
|
||||
|
||||
10
bin/run
Executable file
10
bin/run
Executable file
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Ideally I would use stdin with source, but in older bash versions this
|
||||
# wasn't supported properly.
|
||||
TEMP_ENV_FILE=`mktemp /tmp/redash_env.XXXXXX`
|
||||
sed 's/^REDASH/export REDASH/' .env > $TEMP_ENV_FILE
|
||||
source $TEMP_ENV_FILE
|
||||
rm $TEMP_ENV_FILE
|
||||
|
||||
exec "$@"
|
||||
63
bin/test_multithreading.py
Normal file
63
bin/test_multithreading.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""
|
||||
Script to test concurrency (multithreading/multiprocess) issues with the workers. Use with caution.
|
||||
"""
|
||||
import json
|
||||
import atfork
|
||||
atfork.monkeypatch_os_fork_functions()
|
||||
import atfork.stdlib_fixer
|
||||
atfork.stdlib_fixer.fix_logging_module()
|
||||
|
||||
import time
|
||||
from redash.data import worker
|
||||
from redash import models, data_manager, redis_connection
|
||||
|
||||
if __name__ == '__main__':
|
||||
models.create_db(True, False)
|
||||
|
||||
print "Creating data source..."
|
||||
data_source = models.DataSource.create(name="Concurrency", type="pg", options="dbname=postgres")
|
||||
|
||||
print "Clear jobs/hashes:"
|
||||
redis_connection.delete("jobs")
|
||||
query_hashes = redis_connection.keys("query_hash_*")
|
||||
if query_hashes:
|
||||
redis_connection.delete(*query_hashes)
|
||||
|
||||
starting_query_results_count = models.QueryResult.select().count()
|
||||
jobs_count = 5000
|
||||
workers_count = 10
|
||||
|
||||
print "Creating jobs..."
|
||||
for i in xrange(jobs_count):
|
||||
query = "SELECT {}".format(i)
|
||||
print "Inserting: {}".format(query)
|
||||
data_manager.add_job(query=query, priority=worker.Job.LOW_PRIORITY,
|
||||
data_source=data_source)
|
||||
|
||||
print "Starting workers..."
|
||||
workers = data_manager.start_workers(workers_count)
|
||||
|
||||
print "Waiting for jobs to be done..."
|
||||
keep_waiting = True
|
||||
while keep_waiting:
|
||||
results_count = models.QueryResult.select().count() - starting_query_results_count
|
||||
print "QueryResults: {}".format(results_count)
|
||||
time.sleep(5)
|
||||
if results_count == jobs_count:
|
||||
print "Yay done..."
|
||||
keep_waiting = False
|
||||
|
||||
data_manager.stop_workers()
|
||||
|
||||
qr_count = 0
|
||||
for qr in models.QueryResult.select():
|
||||
number = int(qr.query.split()[1])
|
||||
data_number = json.loads(qr.data)['rows'][0].values()[0]
|
||||
|
||||
if number != data_number:
|
||||
print "Oops? {} != {} ({})".format(number, data_number, qr.id)
|
||||
qr_count += 1
|
||||
|
||||
print "Verified {} query results.".format(qr_count)
|
||||
|
||||
print "Done."
|
||||
@@ -3,29 +3,44 @@ import os
|
||||
import sys
|
||||
import json
|
||||
import requests
|
||||
import subprocess
|
||||
|
||||
|
||||
def capture_output(command):
|
||||
proc = subprocess.Popen(command, stdout=subprocess.PIPE)
|
||||
return proc.stdout.read()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
version = sys.argv[1]
|
||||
filepath = sys.argv[2]
|
||||
filename = filepath.split('/')[-1]
|
||||
github_token = os.environ['GITHUB_TOKEN']
|
||||
auth = (github_token, 'x-oauth-basic')
|
||||
commit_sha = os.environ['CIRCLE_SHA1']
|
||||
version = sys.argv[1]
|
||||
filepath = sys.argv[2]
|
||||
filename = filepath.split('/')[-1]
|
||||
github_token = os.environ['GITHUB_TOKEN']
|
||||
auth = (github_token, 'x-oauth-basic')
|
||||
commit_sha = os.environ['CIRCLE_SHA1']
|
||||
|
||||
params = json.dumps({
|
||||
'tag_name': 'v{0}'.format(version),
|
||||
'name': 're:dash v{0}'.format(version),
|
||||
'target_commitish': commit_sha
|
||||
})
|
||||
commit_body = capture_output(["git", "log", "--format=%b", "-n", "1", commit_sha])
|
||||
file_md5_checksum = capture_output(["md5sum", filepath]).split()[0]
|
||||
file_sha256_checksum = capture_output(["sha256sum", filepath]).split()[0]
|
||||
version_body = "%s\n\nMD5: %s\nSHA256: %s" % (commit_body, file_md5_checksum, file_sha256_checksum)
|
||||
|
||||
response = requests.post('https://api.github.com/repos/everythingme/redash/releases',
|
||||
data=params,
|
||||
auth=auth)
|
||||
params = json.dumps({
|
||||
'tag_name': 'v{0}'.format(version),
|
||||
'name': 're:dash v{0}'.format(version),
|
||||
'body': version_body,
|
||||
'target_commitish': commit_sha,
|
||||
'prerelease': True
|
||||
})
|
||||
|
||||
upload_url = response.json()['upload_url']
|
||||
upload_url = upload_url.replace('{?name}', '')
|
||||
response = requests.post('https://api.github.com/repos/everythingme/redash/releases',
|
||||
data=params,
|
||||
auth=auth)
|
||||
|
||||
with open(filepath) as file_content:
|
||||
headers = {'Content-Type': 'application/gzip'}
|
||||
response = requests.post(upload_url, file_content, params={'name': filename}, auth=auth, headers=headers, verify=False)
|
||||
upload_url = response.json()['upload_url']
|
||||
upload_url = upload_url.replace('{?name}', '')
|
||||
|
||||
with open(filepath) as file_content:
|
||||
headers = {'Content-Type': 'application/gzip'}
|
||||
response = requests.post(upload_url, file_content, params={'name': filename}, auth=auth,
|
||||
headers=headers, verify=False)
|
||||
|
||||
|
||||
15
circle.yml
15
circle.yml
@@ -1,19 +1,28 @@
|
||||
machine:
|
||||
node:
|
||||
version:
|
||||
0.10.22
|
||||
0.10.24
|
||||
python:
|
||||
version:
|
||||
2.7.3
|
||||
dependencies:
|
||||
pre:
|
||||
- make deps
|
||||
- pip install requests
|
||||
- pip install -r dev_requirements.txt
|
||||
- pip install -r requirements.txt
|
||||
cache_directories:
|
||||
- rd_ui/node_modules/
|
||||
- rd_ui/app/bower_components/
|
||||
test:
|
||||
override:
|
||||
- make test
|
||||
post:
|
||||
- make pack
|
||||
- make pack
|
||||
deployment:
|
||||
github:
|
||||
branch: master
|
||||
commands:
|
||||
- make upload
|
||||
notify:
|
||||
webhooks:
|
||||
- url: https://webhooks.gitter.im/e/895d09c3165a0913ac2f
|
||||
|
||||
3
dev_requirements.txt
Normal file
3
dev_requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
nose==1.3.0
|
||||
coverage==3.7.1
|
||||
mock==1.0.1
|
||||
49
manage.py
Executable file
49
manage.py
Executable file
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
CLI to manage redash.
|
||||
"""
|
||||
from flask.ext.script import Manager
|
||||
|
||||
from redash import settings, models, __version__
|
||||
from redash.wsgi import app
|
||||
from redash.import_export import import_manager
|
||||
from redash.cli import users, database, data_sources
|
||||
|
||||
manager = Manager(app)
|
||||
manager.add_command("database", database.manager)
|
||||
manager.add_command("users", users.manager)
|
||||
manager.add_command("import", import_manager)
|
||||
manager.add_command("ds", data_sources.manager)
|
||||
|
||||
|
||||
@manager.command
|
||||
def version():
|
||||
"""Displays re:dash version."""
|
||||
print __version__
|
||||
|
||||
|
||||
@manager.command
|
||||
def runworkers():
|
||||
"""Start workers (deprecated)."""
|
||||
print "** This command is deprecated. Please use Celery's CLI to control the workers. **"
|
||||
|
||||
|
||||
@manager.shell
|
||||
def make_shell_context():
|
||||
from redash.models import db
|
||||
return dict(app=app, db=db, models=models)
|
||||
|
||||
|
||||
@manager.command
|
||||
def check_settings():
|
||||
"""Show the settings as re:dash sees them (useful for debugging)."""
|
||||
from types import ModuleType
|
||||
|
||||
for name in dir(settings):
|
||||
item = getattr(settings, name)
|
||||
if not callable(item) and not name.startswith("__") and not isinstance(item, ModuleType):
|
||||
print "{} = {}".format(name, item)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
manager.run()
|
||||
13
migrations/add_created_at_field.py
Normal file
13
migrations/add_created_at_field.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from playhouse.migrate import Migrator
|
||||
from redash import db
|
||||
from redash import models
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
db.connect_db()
|
||||
migrator = Migrator(db.database)
|
||||
with db.database.transaction():
|
||||
migrator.add_column(models.Dashboard, models.Dashboard.created_at, 'created_at')
|
||||
migrator.add_column(models.Widget, models.Widget.created_at, 'created_at')
|
||||
|
||||
db.close_db(None)
|
||||
12
migrations/add_global_filters_to_dashboard.py
Normal file
12
migrations/add_global_filters_to_dashboard.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from playhouse.migrate import Migrator
|
||||
from redash import models
|
||||
from redash.models import db
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
db.connect_db()
|
||||
migrator = Migrator(db.database)
|
||||
with db.database.transaction():
|
||||
migrator.add_column(models.Dashboard, models.Dashboard.dashboard_filters_enabled, 'dashboard_filters_enabled')
|
||||
|
||||
db.close_db(None)
|
||||
12
migrations/add_password_to_users.py
Normal file
12
migrations/add_password_to_users.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from playhouse.migrate import Migrator
|
||||
from redash import db
|
||||
from redash import models
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
db.connect_db()
|
||||
migrator = Migrator(db.database)
|
||||
with db.database.transaction():
|
||||
migrator.add_column(models.User, models.User.password_hash, 'password_hash')
|
||||
|
||||
db.close_db(None)
|
||||
13
migrations/add_permissions_to_user.py
Normal file
13
migrations/add_permissions_to_user.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from playhouse.migrate import Migrator
|
||||
from redash import db
|
||||
from redash import models
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
db.connect_db()
|
||||
migrator = Migrator(db.database)
|
||||
with db.database.transaction():
|
||||
migrator.add_column(models.User, models.User.permissions, 'permissions')
|
||||
models.User.update(permissions=['admin'] + models.User.DEFAULT_PERMISSIONS).where(models.User.is_admin == True).execute()
|
||||
|
||||
db.close_db(None)
|
||||
13
migrations/add_queue_name_to_data_source.py
Normal file
13
migrations/add_queue_name_to_data_source.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from playhouse.migrate import Migrator
|
||||
from redash.models import db
|
||||
from redash import models
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
db.connect_db()
|
||||
migrator = Migrator(db.database)
|
||||
with db.database.transaction():
|
||||
migrator.add_column(models.DataSource, models.DataSource.queue_name, 'queue_name')
|
||||
migrator.add_column(models.DataSource, models.DataSource.scheduled_queue_name, 'scheduled_queue_name')
|
||||
|
||||
db.close_db(None)
|
||||
13
migrations/add_text_to_widgets.py
Normal file
13
migrations/add_text_to_widgets.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from playhouse.migrate import Migrator
|
||||
from redash.models import db
|
||||
from redash import models
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
db.connect_db()
|
||||
migrator = Migrator(db.database)
|
||||
with db.database.transaction():
|
||||
migrator.add_column(models.Widget, models.Widget.text, 'text')
|
||||
migrator.set_nullable(models.Widget, models.Widget.visualization, True)
|
||||
|
||||
db.close_db(None)
|
||||
13
migrations/add_view_query_permission.py
Normal file
13
migrations/add_view_query_permission.py
Normal file
@@ -0,0 +1,13 @@
|
||||
import peewee
|
||||
from redash import db
|
||||
from redash import models
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
db.connect_db()
|
||||
|
||||
previous_default_permissions = models.User.DEFAULT_PERMISSIONS[:]
|
||||
previous_default_permissions.remove('view_query')
|
||||
models.User.update(permissions=peewee.fn.array_append(models.User.permissions, 'view_query')).where(peewee.SQL("'view_source' = any(permissions)")).execute()
|
||||
|
||||
db.close_db(None)
|
||||
12
migrations/change_queries_description_to_nullable.py
Normal file
12
migrations/change_queries_description_to_nullable.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from playhouse.migrate import Migrator
|
||||
from redash import db
|
||||
from redash import models
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
db.connect_db()
|
||||
migrator = Migrator(db.database)
|
||||
with db.database.transaction():
|
||||
migrator.set_nullable(models.Query, models.Query.description, True)
|
||||
|
||||
db.close_db(None)
|
||||
13
migrations/change_query_id_on_widgets_to_null.py
Normal file
13
migrations/change_query_id_on_widgets_to_null.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from playhouse.migrate import Migrator
|
||||
from redash import db
|
||||
from redash import models
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
db.connect_db()
|
||||
migrator = Migrator(db.database)
|
||||
with db.database.transaction():
|
||||
migrator.set_nullable(models.Widget, models.Widget.query_id, True)
|
||||
migrator.set_nullable(models.Widget, models.Widget.type, True)
|
||||
|
||||
db.close_db(None)
|
||||
11
migrations/create_activity_log.py
Normal file
11
migrations/create_activity_log.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from redash import db
|
||||
from redash import models
|
||||
|
||||
if __name__ == '__main__':
|
||||
db.connect_db()
|
||||
|
||||
if not models.ActivityLog.table_exists():
|
||||
print "Creating activity_log table..."
|
||||
models.ActivityLog.create_table()
|
||||
|
||||
db.close_db(None)
|
||||
48
migrations/create_data_sources.py
Normal file
48
migrations/create_data_sources.py
Normal file
@@ -0,0 +1,48 @@
|
||||
import logging
|
||||
import peewee
|
||||
from playhouse.migrate import Migrator
|
||||
from redash import db
|
||||
from redash import models
|
||||
from redash import settings
|
||||
|
||||
if __name__ == '__main__':
|
||||
db.connect_db()
|
||||
|
||||
if not models.DataSource.table_exists():
|
||||
print "Creating data_sources table..."
|
||||
models.DataSource.create_table()
|
||||
|
||||
default_data_source = models.DataSource.create(name="Default",
|
||||
type=settings.CONNECTION_ADAPTER,
|
||||
options=settings.CONNECTION_STRING)
|
||||
else:
|
||||
default_data_source = models.DataSource.select().first()
|
||||
|
||||
migrator = Migrator(db.database)
|
||||
models.Query.data_source.null = True
|
||||
models.QueryResult.data_source.null = True
|
||||
try:
|
||||
with db.database.transaction():
|
||||
migrator.add_column(models.Query, models.Query.data_source, "data_source_id")
|
||||
except peewee.ProgrammingError:
|
||||
print "Failed to create data_source_id column -- assuming it already exists"
|
||||
|
||||
try:
|
||||
with db.database.transaction():
|
||||
migrator.add_column(models.QueryResult, models.QueryResult.data_source, "data_source_id")
|
||||
except peewee.ProgrammingError:
|
||||
print "Failed to create data_source_id column -- assuming it already exists"
|
||||
|
||||
print "Updating data source to existing one..."
|
||||
models.Query.update(data_source=default_data_source.id).execute()
|
||||
models.QueryResult.update(data_source=default_data_source.id).execute()
|
||||
|
||||
with db.database.transaction():
|
||||
print "Setting data source to non nullable..."
|
||||
migrator.set_nullable(models.Query, models.Query.data_source, False)
|
||||
|
||||
with db.database.transaction():
|
||||
print "Setting data source to non nullable..."
|
||||
migrator.set_nullable(models.QueryResult, models.QueryResult.data_source, False)
|
||||
|
||||
db.close_db(None)
|
||||
12
migrations/create_events.py
Normal file
12
migrations/create_events.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from redash.models import db
|
||||
from redash import models
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
db.connect_db()
|
||||
|
||||
if not models.Event.table_exists():
|
||||
print "Creating events table..."
|
||||
models.Event.create_table()
|
||||
|
||||
db.close_db(None)
|
||||
56
migrations/create_users.py
Normal file
56
migrations/create_users.py
Normal file
@@ -0,0 +1,56 @@
|
||||
import json
|
||||
import itertools
|
||||
import peewee
|
||||
from playhouse.migrate import Migrator
|
||||
from redash import db, settings
|
||||
from redash import models
|
||||
|
||||
if __name__ == '__main__':
|
||||
db.connect_db()
|
||||
|
||||
if not models.User.table_exists():
|
||||
print "Creating user table..."
|
||||
models.User.create_table()
|
||||
|
||||
migrator = Migrator(db.database)
|
||||
with db.database.transaction():
|
||||
print "Creating user field on dashboard and queries..."
|
||||
try:
|
||||
migrator.rename_column(models.Query, '"user"', "user_email")
|
||||
migrator.rename_column(models.Dashboard, '"user"', "user_email")
|
||||
except peewee.ProgrammingError:
|
||||
print "Failed to rename user column -- assuming it already exists"
|
||||
|
||||
with db.database.transaction():
|
||||
models.Query.user.null = True
|
||||
models.Dashboard.user.null = True
|
||||
|
||||
try:
|
||||
migrator.add_column(models.Query, models.Query.user, "user_id")
|
||||
migrator.add_column(models.Dashboard, models.Dashboard.user, "user_id")
|
||||
except peewee.ProgrammingError:
|
||||
print "Failed to create user_id column -- assuming it already exists"
|
||||
|
||||
print "Creating user for all queries and dashboards..."
|
||||
for obj in itertools.chain(models.Query.select(), models.Dashboard.select()):
|
||||
# Some old databases might have queries with empty string as user email:
|
||||
email = obj.user_email or settings.ADMINS[0]
|
||||
email = email.split(',')[0]
|
||||
|
||||
print ".. {} , {}, {}".format(type(obj), obj.id, email)
|
||||
|
||||
try:
|
||||
user = models.User.get(models.User.email == email)
|
||||
except models.User.DoesNotExist:
|
||||
is_admin = email in settings.ADMINS
|
||||
user = models.User.create(email=email, name=email, is_admin=is_admin)
|
||||
|
||||
obj.user = user
|
||||
obj.save()
|
||||
|
||||
print "Set user_id to non null..."
|
||||
with db.database.transaction():
|
||||
migrator.set_nullable(models.Query, models.Query.user, False)
|
||||
migrator.set_nullable(models.Dashboard, models.Dashboard.user, False)
|
||||
migrator.set_nullable(models.Query, models.Query.user_email, True)
|
||||
migrator.set_nullable(models.Dashboard, models.Dashboard.user_email, True)
|
||||
70
migrations/create_visualizations.py
Normal file
70
migrations/create_visualizations.py
Normal file
@@ -0,0 +1,70 @@
|
||||
import json
|
||||
from playhouse.migrate import Migrator
|
||||
from redash import db
|
||||
from redash import models
|
||||
|
||||
if __name__ == '__main__':
|
||||
default_options = {"series": {"type": "column"}}
|
||||
|
||||
db.connect_db()
|
||||
|
||||
if not models.Visualization.table_exists():
|
||||
print "Creating visualization table..."
|
||||
models.Visualization.create_table()
|
||||
|
||||
with db.database.transaction():
|
||||
migrator = Migrator(db.database)
|
||||
print "Adding visualization_id to widgets:"
|
||||
field = models.Widget.visualization
|
||||
field.null = True
|
||||
migrator.add_column(models.Widget, models.Widget.visualization, 'visualization_id')
|
||||
|
||||
print 'Creating TABLE visualizations for all queries...'
|
||||
for query in models.Query.select():
|
||||
vis = models.Visualization(query=query, name="Table",
|
||||
description=query.description or "",
|
||||
type="TABLE", options="{}")
|
||||
vis.save()
|
||||
|
||||
print 'Creating COHORT visualizations for all queries named like %cohort%...'
|
||||
for query in models.Query.select().where(models.Query.name ** "%cohort%"):
|
||||
vis = models.Visualization(query=query, name="Cohort",
|
||||
description=query.description or "",
|
||||
type="COHORT", options="{}")
|
||||
vis.save()
|
||||
|
||||
print 'Create visualization for all widgets (unless exists already):'
|
||||
for widget in models.Widget.select():
|
||||
print 'Processing widget id: %d:' % widget.id
|
||||
vis_type = widget.type.upper()
|
||||
if vis_type == 'GRID':
|
||||
vis_type = 'TABLE'
|
||||
|
||||
query = models.Query.get_by_id(widget.query_id)
|
||||
vis = query.visualizations.where(models.Visualization.type == vis_type).first()
|
||||
if vis:
|
||||
print '... visualization type (%s) found.' % vis_type
|
||||
widget.visualization = vis
|
||||
widget.save()
|
||||
else:
|
||||
vis_name = vis_type.title()
|
||||
|
||||
options = json.loads(widget.options)
|
||||
vis_options = {"series": options} if options else default_options
|
||||
vis_options = json.dumps(vis_options)
|
||||
|
||||
vis = models.Visualization(query=query, name=vis_name,
|
||||
description=query.description or "",
|
||||
type=vis_type, options=vis_options)
|
||||
|
||||
print '... Created visualization for type: %s' % vis_type
|
||||
vis.save()
|
||||
widget.visualization = vis
|
||||
widget.save()
|
||||
|
||||
with db.database.transaction():
|
||||
migrator = Migrator(db.database)
|
||||
print "Setting visualization_id as not null..."
|
||||
migrator.set_nullable(models.Widget, models.Widget.visualization, False)
|
||||
|
||||
db.close_db(None)
|
||||
29
migrations/permissions_migration.py
Normal file
29
migrations/permissions_migration.py
Normal file
@@ -0,0 +1,29 @@
|
||||
import peewee
|
||||
from playhouse.migrate import Migrator
|
||||
from redash import models
|
||||
from redash.models import db
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
db.connect_db()
|
||||
migrator = Migrator(db.database)
|
||||
|
||||
if not models.Group.table_exists():
|
||||
print "Creating groups table..."
|
||||
models.Group.create_table()
|
||||
|
||||
with db.database.transaction():
|
||||
models.Group.insert(name='admin', permissions=['admin'], tables=['*']).execute()
|
||||
models.Group.insert(name='api', permissions=['view_query'], tables=['*']).execute()
|
||||
models.Group.insert(name='default', permissions=models.Group.DEFAULT_PERMISSIONS, tables=['*']).execute()
|
||||
|
||||
migrator.add_column(models.User, models.User.groups, 'groups')
|
||||
|
||||
models.User.update(groups=['admin', 'default']).where(peewee.SQL("is_admin = true")).execute()
|
||||
models.User.update(groups=['admin', 'default']).where(peewee.SQL("'admin' = any(permissions)")).execute()
|
||||
models.User.update(groups=['default']).where(peewee.SQL("is_admin = false")).execute()
|
||||
|
||||
migrator.drop_column(models.User, 'permissions')
|
||||
migrator.drop_column(models.User, 'is_admin')
|
||||
|
||||
db.close_db(None)
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
"""
|
||||
CLI to start the workers.
|
||||
|
||||
TODO: move API server startup here.
|
||||
"""
|
||||
import atfork
|
||||
atfork.monkeypatch_os_fork_functions()
|
||||
import atfork.stdlib_fixer
|
||||
atfork.stdlib_fixer.fix_logging_module()
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import urlparse
|
||||
import redis
|
||||
import time
|
||||
import settings
|
||||
import data
|
||||
|
||||
|
||||
def start_workers(data_manager):
|
||||
try:
|
||||
old_workers = data_manager.redis_connection.smembers('workers')
|
||||
data_manager.redis_connection.delete('workers')
|
||||
|
||||
logging.info("Cleaning old workers: %s", old_workers)
|
||||
|
||||
data_manager.start_workers(settings.WORKERS_COUNT, settings.CONNECTION_STRING)
|
||||
logging.info("Workers started.")
|
||||
|
||||
while True:
|
||||
try:
|
||||
data_manager.refresh_queries()
|
||||
except Exception as e:
|
||||
logging.error("Something went wrong with refreshing queries...")
|
||||
logging.exception(e)
|
||||
time.sleep(60)
|
||||
except KeyboardInterrupt:
|
||||
logging.warning("Exiting; waiting for threads")
|
||||
data_manager.stop_workers()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
channel = logging.StreamHandler()
|
||||
logging.getLogger().addHandler(channel)
|
||||
logging.getLogger().setLevel(settings.LOG_LEVEL)
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("command")
|
||||
args = parser.parse_args()
|
||||
|
||||
url = urlparse.urlparse(settings.REDIS_URL)
|
||||
redis_connection = redis.StrictRedis(host=url.hostname, port=url.port, db=0, password=url.password)
|
||||
data_manager = data.Manager(redis_connection, settings.INTERNAL_DB_CONNECTION_STRING, settings.MAX_CONNECTIONS)
|
||||
|
||||
if args.command == "worker":
|
||||
start_workers(data_manager)
|
||||
else:
|
||||
print "Unknown command"
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
from manager import Manager
|
||||
from worker import Job
|
||||
import models
|
||||
import utils
|
||||
@@ -1,192 +0,0 @@
|
||||
"""
|
||||
Data manager. Used to manage and coordinate execution of queries.
|
||||
"""
|
||||
import collections
|
||||
from contextlib import contextmanager
|
||||
import json
|
||||
import logging
|
||||
import psycopg2
|
||||
import qr
|
||||
import redis
|
||||
import time
|
||||
import worker
|
||||
import settings
|
||||
from utils import gen_query_hash
|
||||
|
||||
|
||||
class QueryResult(collections.namedtuple('QueryData', 'id query data runtime retrieved_at query_hash')):
|
||||
def to_dict(self, parse_data=False):
|
||||
d = self._asdict()
|
||||
|
||||
if parse_data and d['data']:
|
||||
d['data'] = json.loads(d['data'])
|
||||
|
||||
return d
|
||||
|
||||
|
||||
class Manager(object):
|
||||
def __init__(self, redis_connection, db_connection_string, db_max_connections):
|
||||
self.redis_connection = redis_connection
|
||||
self.workers = []
|
||||
self.db_connection_string = db_connection_string
|
||||
self.queue = qr.PriorityQueue("jobs", **self.redis_connection.connection_pool.connection_kwargs)
|
||||
self.max_retries = 5
|
||||
self.status = {
|
||||
'last_refresh_at': 0,
|
||||
'started_at': time.time()
|
||||
}
|
||||
|
||||
self._save_status()
|
||||
|
||||
# TODO: Use our Django Models
|
||||
def get_query_result_by_id(self, query_result_id):
|
||||
with self.db_transaction() as cursor:
|
||||
sql = "SELECT id, query, data, runtime, retrieved_at, query_hash FROM query_results " \
|
||||
"WHERE id=%s LIMIT 1"
|
||||
cursor.execute(sql, (query_result_id,))
|
||||
query_result = cursor.fetchone()
|
||||
|
||||
if query_result:
|
||||
query_result = QueryResult(*query_result)
|
||||
|
||||
return query_result
|
||||
|
||||
def get_query_result(self, query, ttl=0):
|
||||
query_hash = gen_query_hash(query)
|
||||
|
||||
with self.db_transaction() as cursor:
|
||||
sql = "SELECT id, query, data, runtime, retrieved_at, query_hash FROM query_results " \
|
||||
"WHERE query_hash=%s " \
|
||||
"AND retrieved_at < now() at time zone 'utc' - interval '%s second'" \
|
||||
"ORDER BY retrieved_at DESC LIMIT 1"
|
||||
cursor.execute(sql, (query_hash, psycopg2.extensions.AsIs(ttl)))
|
||||
query_result = cursor.fetchone()
|
||||
|
||||
if query_result:
|
||||
query_result = QueryResult(*query_result)
|
||||
|
||||
return query_result
|
||||
|
||||
def add_job(self, query, priority):
|
||||
query_hash = gen_query_hash(query)
|
||||
logging.info("[Manager][%s] Inserting job with priority=%s", query_hash, priority)
|
||||
try_count = 0
|
||||
job = None
|
||||
|
||||
while try_count < self.max_retries:
|
||||
try_count += 1
|
||||
|
||||
pipe = self.redis_connection.pipeline()
|
||||
try:
|
||||
pipe.watch('query_hash_job:%s' % query_hash)
|
||||
job_id = pipe.get('query_hash_job:%s' % query_hash)
|
||||
if job_id:
|
||||
logging.info("[Manager][%s] Found existing job: %s", query_hash, job_id)
|
||||
job = worker.Job.load(self.redis_connection, job_id)
|
||||
else:
|
||||
job = worker.Job(self.redis_connection, query, priority)
|
||||
pipe.multi()
|
||||
job.save(pipe)
|
||||
logging.info("[Manager][%s] Created new job: %s", query_hash, job.id)
|
||||
self.queue.push(job.id, job.priority)
|
||||
break
|
||||
|
||||
except redis.WatchError:
|
||||
continue
|
||||
|
||||
if not job:
|
||||
logging.error("[Manager][%s] Failed adding job for query.", query_hash)
|
||||
|
||||
return job
|
||||
|
||||
def refresh_queries(self):
|
||||
sql = """SELECT queries.query, queries.ttl, retrieved_at
|
||||
FROM (SELECT query, min(ttl) as ttl FROM queries WHERE ttl > 0 GROUP by query) queries
|
||||
JOIN (SELECT query, max(retrieved_at) as retrieved_at
|
||||
FROM query_results
|
||||
GROUP BY query) query_results on query_results.query=queries.query
|
||||
WHERE queries.ttl > 0
|
||||
AND query_results.retrieved_at + ttl * interval '1 second' < now() at time zone 'utc';"""
|
||||
|
||||
self.status['last_refresh_at'] = time.time()
|
||||
self._save_status()
|
||||
|
||||
logging.info("Refreshing queries...")
|
||||
queries = self.run_query(sql)
|
||||
for query, ttl, retrieved_at in queries:
|
||||
self.add_job(query, worker.Job.LOW_PRIORITY)
|
||||
|
||||
logging.info("Done refreshing queries... %d" % len(queries))
|
||||
|
||||
def store_query_result(self, query, data, run_time, retrieved_at):
|
||||
query_result_id = None
|
||||
query_hash = gen_query_hash(query)
|
||||
sql = "INSERT INTO query_results (query_hash, query, data, runtime, retrieved_at) " \
|
||||
"VALUES (%s, %s, %s, %s, %s) RETURNING id"
|
||||
with self.db_transaction() as cursor:
|
||||
cursor.execute(sql, (query_hash, query, data, run_time, retrieved_at))
|
||||
if cursor.rowcount == 1:
|
||||
query_result_id = cursor.fetchone()[0]
|
||||
logging.info("[Manager][%s] Inserted query data; id=%s", query_hash, query_result_id)
|
||||
|
||||
sql = "UPDATE queries SET latest_query_data_id=%s WHERE query_hash=%s"
|
||||
cursor.execute(sql, (query_result_id, query_hash))
|
||||
|
||||
logging.info("[Manager][%s] Updated %s queries.", query_hash, cursor.rowcount)
|
||||
else:
|
||||
logging.error("[Manager][%s] Failed inserting query data.", query_hash)
|
||||
return query_result_id
|
||||
|
||||
def run_query(self, *args):
|
||||
sql = args[0]
|
||||
logging.debug("running query: %s %s", sql, args[1:])
|
||||
|
||||
with self.db_transaction() as cursor:
|
||||
cursor.execute(sql, args[1:])
|
||||
if cursor.description:
|
||||
data = list(cursor)
|
||||
else:
|
||||
data = cursor.rowcount
|
||||
|
||||
return data
|
||||
|
||||
def start_workers(self, workers_count, connection_string):
|
||||
if self.workers:
|
||||
return self.workers
|
||||
|
||||
if settings.CONNECTION_ADAPTER == "mysql":
|
||||
import query_runner_mysql
|
||||
runner = query_runner_mysql.mysql(connection_string)
|
||||
else:
|
||||
import query_runner
|
||||
runner = query_runner.redshift(connection_string)
|
||||
|
||||
redis_connection_params = self.redis_connection.connection_pool.connection_kwargs
|
||||
self.workers = [worker.Worker(self, redis_connection_params, runner)
|
||||
for _ in range(workers_count)]
|
||||
for w in self.workers:
|
||||
w.start()
|
||||
|
||||
return self.workers
|
||||
|
||||
def stop_workers(self):
|
||||
for w in self.workers:
|
||||
w.continue_working = False
|
||||
w.join()
|
||||
|
||||
@contextmanager
|
||||
def db_transaction(self):
|
||||
connection = psycopg2.connect(self.db_connection_string)
|
||||
cursor = connection.cursor()
|
||||
try:
|
||||
yield cursor
|
||||
except:
|
||||
connection.rollback()
|
||||
raise
|
||||
else:
|
||||
connection.commit()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def _save_status(self):
|
||||
self.redis_connection.hmset('manager:status', self.status)
|
||||
@@ -1,210 +0,0 @@
|
||||
"""
|
||||
Django ORM based models to describe the data model of re:dash.
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
from django.db import models
|
||||
from django.template.defaultfilters import slugify
|
||||
import utils
|
||||
|
||||
|
||||
class QueryResult(models.Model):
|
||||
id = models.AutoField(primary_key=True)
|
||||
query_hash = models.CharField(max_length=32)
|
||||
query = models.TextField()
|
||||
data = models.TextField()
|
||||
runtime = models.FloatField()
|
||||
retrieved_at = models.DateTimeField()
|
||||
|
||||
class Meta:
|
||||
app_label = 'redash'
|
||||
db_table = 'query_results'
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'query_hash': self.query_hash,
|
||||
'query': self.query,
|
||||
'data': json.loads(self.data),
|
||||
'runtime': self.runtime,
|
||||
'retrieved_at': self.retrieved_at
|
||||
}
|
||||
|
||||
def __unicode__(self):
|
||||
return u"%d | %s | %s" % (self.id, self.query_hash, self.retrieved_at)
|
||||
|
||||
|
||||
class Query(models.Model):
|
||||
id = models.AutoField(primary_key=True)
|
||||
latest_query_data = models.ForeignKey(QueryResult)
|
||||
name = models.CharField(max_length=255)
|
||||
description = models.CharField(max_length=4096)
|
||||
query = models.TextField()
|
||||
query_hash = models.CharField(max_length=32)
|
||||
api_key = models.CharField(max_length=40)
|
||||
ttl = models.IntegerField()
|
||||
user = models.CharField(max_length=360)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
app_label = 'redash'
|
||||
db_table = 'queries'
|
||||
|
||||
def to_dict(self, with_result=True, with_stats=False,
|
||||
with_visualizations=False):
|
||||
d = {
|
||||
'id': self.id,
|
||||
'latest_query_data_id': self.latest_query_data_id,
|
||||
'name': self.name,
|
||||
'description': self.description,
|
||||
'query': self.query,
|
||||
'query_hash': self.query_hash,
|
||||
'ttl': self.ttl,
|
||||
'user': self.user,
|
||||
'api_key': self.api_key,
|
||||
'created_at': self.created_at,
|
||||
}
|
||||
|
||||
if with_stats:
|
||||
d['avg_runtime'] = self.avg_runtime
|
||||
d['min_runtime'] = self.min_runtime
|
||||
d['max_runtime'] = self.max_runtime
|
||||
d['last_retrieved_at'] = self.last_retrieved_at
|
||||
d['times_retrieved'] = self.times_retrieved
|
||||
|
||||
if with_result and self.latest_query_data_id:
|
||||
d['latest_query_data'] = self.latest_query_data.to_dict()
|
||||
|
||||
if with_visualizations:
|
||||
d['visualizations'] = [vis.to_dict(with_query=False)
|
||||
for vis in self.visualizations.all()]
|
||||
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
def all_queries(cls):
|
||||
query = """SELECT queries.*, query_stats.*
|
||||
FROM queries
|
||||
LEFT OUTER JOIN
|
||||
(SELECT qu.query_hash,
|
||||
count(0) AS "times_retrieved",
|
||||
avg(runtime) AS "avg_runtime",
|
||||
min(runtime) AS "min_runtime",
|
||||
max(runtime) AS "max_runtime",
|
||||
max(retrieved_at) AS "last_retrieved_at"
|
||||
FROM queries qu
|
||||
JOIN query_results qr ON qu.query_hash=qr.query_hash
|
||||
GROUP BY qu.query_hash) query_stats ON query_stats.query_hash = queries.query_hash
|
||||
"""
|
||||
return cls.objects.raw(query)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.query_hash = utils.gen_query_hash(self.query)
|
||||
self._set_api_key()
|
||||
super(Query, self).save(*args, **kwargs)
|
||||
|
||||
def _set_api_key(self):
|
||||
if not self.api_key:
|
||||
self.api_key = hashlib.sha1(
|
||||
u''.join([str(time.time()), self.query, self.user, self.name])).hexdigest()
|
||||
|
||||
def __unicode__(self):
|
||||
return unicode(self.id)
|
||||
|
||||
|
||||
class Dashboard(models.Model):
|
||||
id = models.AutoField(primary_key=True)
|
||||
slug = models.CharField(max_length=140)
|
||||
name = models.CharField(max_length=100)
|
||||
user = models.CharField(max_length=360)
|
||||
layout = models.TextField()
|
||||
is_archived = models.BooleanField(default=False)
|
||||
|
||||
class Meta:
|
||||
app_label = 'redash'
|
||||
db_table = 'dashboards'
|
||||
|
||||
def to_dict(self, with_widgets=False):
|
||||
layout = json.loads(self.layout)
|
||||
|
||||
if with_widgets:
|
||||
widgets = {w.id: w.to_dict() for w in self.widgets.all()}
|
||||
widgets_layout = map(lambda row: map(lambda widget_id: widgets.get(widget_id, None), row), layout)
|
||||
else:
|
||||
widgets_layout = None
|
||||
|
||||
return {
|
||||
'id': self.id,
|
||||
'slug': self.slug,
|
||||
'name': self.name,
|
||||
'user': self.user,
|
||||
'layout': layout,
|
||||
'widgets': widgets_layout
|
||||
}
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# TODO: make sure slug is unique
|
||||
if not self.slug:
|
||||
self.slug = slugify(self.name)
|
||||
super(Dashboard, self).save(*args, **kwargs)
|
||||
|
||||
def __unicode__(self):
|
||||
return u"%s=%s" % (self.id, self.name)
|
||||
|
||||
|
||||
class Visualization(models.Model):
|
||||
id = models.AutoField(primary_key=True)
|
||||
type = models.CharField(max_length=100)
|
||||
query = models.ForeignKey(Query, related_name='visualizations')
|
||||
name = models.CharField(max_length=255)
|
||||
description = models.CharField(max_length=4096)
|
||||
options = models.TextField()
|
||||
|
||||
class Meta:
|
||||
app_label = 'redash'
|
||||
db_table = 'visualizations'
|
||||
|
||||
def to_dict(self, with_query=True):
|
||||
d = {
|
||||
'id': self.id,
|
||||
'type': self.type,
|
||||
'name': self.name,
|
||||
'description': self.description,
|
||||
'options': json.loads(self.options),
|
||||
}
|
||||
|
||||
if with_query:
|
||||
d['query'] = self.query.to_dict()
|
||||
|
||||
return d
|
||||
|
||||
def __unicode__(self):
|
||||
return u"%s=>%s" % (self.id, self.query_id)
|
||||
|
||||
|
||||
class Widget(models.Model):
|
||||
id = models.AutoField(primary_key=True)
|
||||
type = models.CharField(max_length=100)
|
||||
query = models.ForeignKey(Query, related_name='widgets')
|
||||
visualization = models.ForeignKey(Visualization, related_name='widgets')
|
||||
width = models.IntegerField()
|
||||
options = models.TextField()
|
||||
dashboard = models.ForeignKey(Dashboard, related_name='widgets')
|
||||
|
||||
class Meta:
|
||||
app_label = 'redash'
|
||||
db_table = 'widgets'
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'type': self.type,
|
||||
'width': self.width,
|
||||
'options': json.loads(self.options),
|
||||
'visualization': self.visualization.to_dict(),
|
||||
'dashboard_id': self.dashboard_id
|
||||
}
|
||||
|
||||
def __unicode__(self):
|
||||
return u"%s=>%s" % (self.id, self.dashboard_id)
|
||||
@@ -1,67 +0,0 @@
|
||||
"""
|
||||
QueryRunner is the function that the workers use, to execute queries. This is the Redshift
|
||||
(PostgreSQL in fact) version, but easily we can write another to support additional databases
|
||||
(MySQL and others).
|
||||
|
||||
Because the worker just pass the query, this can be used with any data store that has some sort of
|
||||
query language (for example: HiveQL).
|
||||
"""
|
||||
import logging
|
||||
import json
|
||||
import psycopg2
|
||||
import sys
|
||||
import select
|
||||
from .utils import JSONEncoder
|
||||
|
||||
def redshift(connection_string):
|
||||
def column_friendly_name(column_name):
|
||||
return column_name
|
||||
|
||||
def wait(conn):
|
||||
while 1:
|
||||
state = conn.poll()
|
||||
if state == psycopg2.extensions.POLL_OK:
|
||||
break
|
||||
elif state == psycopg2.extensions.POLL_WRITE:
|
||||
select.select([], [conn.fileno()], [])
|
||||
elif state == psycopg2.extensions.POLL_READ:
|
||||
select.select([conn.fileno()], [], [])
|
||||
else:
|
||||
raise psycopg2.OperationalError("poll() returned %s" % state)
|
||||
|
||||
def query_runner(query):
|
||||
connection = psycopg2.connect(connection_string, async=True)
|
||||
wait(connection)
|
||||
|
||||
cursor = connection.cursor()
|
||||
|
||||
try:
|
||||
cursor.execute(query)
|
||||
wait(connection)
|
||||
|
||||
column_names = [col.name for col in cursor.description]
|
||||
|
||||
rows = [dict(zip(column_names, row)) for row in cursor]
|
||||
columns = [{'name': col.name,
|
||||
'friendly_name': column_friendly_name(col.name),
|
||||
'type': None} for col in cursor.description]
|
||||
|
||||
data = {'columns': columns, 'rows': rows}
|
||||
json_data = json.dumps(data, cls=JSONEncoder)
|
||||
error = None
|
||||
cursor.close()
|
||||
except psycopg2.DatabaseError as e:
|
||||
json_data = None
|
||||
error = e.message
|
||||
except KeyboardInterrupt:
|
||||
connection.cancel()
|
||||
error = "Query cancelled by user."
|
||||
json_data = None
|
||||
except Exception as e:
|
||||
raise sys.exc_info()[1], None, sys.exc_info()[2]
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
return json_data, error
|
||||
|
||||
return query_runner
|
||||
@@ -1,56 +0,0 @@
|
||||
BEGIN;
|
||||
CREATE TABLE "query_results" (
|
||||
"id" serial NOT NULL PRIMARY KEY,
|
||||
"query_hash" varchar(32) NOT NULL,
|
||||
"query" text NOT NULL,
|
||||
"data" text NOT NULL,
|
||||
"runtime" double precision NOT NULL,
|
||||
"retrieved_at" timestamp with time zone NOT NULL
|
||||
)
|
||||
;
|
||||
CREATE TABLE "queries" (
|
||||
"id" serial NOT NULL PRIMARY KEY,
|
||||
"latest_query_data_id" integer REFERENCES "query_results" ("id") DEFERRABLE INITIALLY DEFERRED,
|
||||
"name" varchar(255) NOT NULL,
|
||||
"description" varchar(4096),
|
||||
"query" text NOT NULL,
|
||||
"query_hash" varchar(32) NOT NULL,
|
||||
"api_key" varchar(40),
|
||||
"ttl" integer NOT NULL,
|
||||
"user" varchar(360) NOT NULL,
|
||||
"created_at" timestamp with time zone NOT NULL
|
||||
)
|
||||
;
|
||||
CREATE TABLE "dashboards" (
|
||||
"id" serial NOT NULL PRIMARY KEY,
|
||||
"slug" varchar(140) NOT NULL,
|
||||
"name" varchar(100) NOT NULL,
|
||||
"user" varchar(360) NOT NULL,
|
||||
"layout" text NOT NULL,
|
||||
"is_archived" boolean NOT NULL
|
||||
)
|
||||
;
|
||||
CREATE TABLE "visualizations" (
|
||||
"id" serial NOT NULL PRIMARY KEY,
|
||||
"type" varchar(100) NOT NULL,
|
||||
"query_id" integer NOT NULL REFERENCES "queries" ("id") DEFERRABLE INITIALLY DEFERRED,
|
||||
"name" varchar(255) NOT NULL,
|
||||
"description" varchar(4096),
|
||||
"options" text NOT NULL
|
||||
)
|
||||
;
|
||||
CREATE TABLE "widgets" (
|
||||
"id" serial NOT NULL PRIMARY KEY,
|
||||
"type" varchar(100) NOT NULL,
|
||||
"width" integer NOT NULL,
|
||||
"options" text NOT NULL,
|
||||
"query_id" integer,
|
||||
"visualization_id" integer NOT NULL REFERENCES "visualizations" ("id") DEFERRABLE INITIALLY DEFERRED,
|
||||
"dashboard_id" integer NOT NULL REFERENCES "dashboards" ("id") DEFERRABLE INITIALLY DEFERRED
|
||||
)
|
||||
;
|
||||
CREATE INDEX "queries_latest_query_data_id" ON "queries" ("latest_query_data_id");
|
||||
CREATE INDEX "widgets_query_id" ON "widgets" ("query_id");
|
||||
CREATE INDEX "widgets_dashboard_id" ON "widgets" ("dashboard_id");
|
||||
|
||||
COMMIT;
|
||||
@@ -1,71 +0,0 @@
|
||||
import cStringIO
|
||||
import csv
|
||||
import codecs
|
||||
import decimal
|
||||
import datetime
|
||||
import json
|
||||
import re
|
||||
import hashlib
|
||||
|
||||
COMMENTS_REGEX = re.compile("/\*.*?\*/")
|
||||
|
||||
|
||||
def gen_query_hash(sql):
|
||||
"""Returns hash of the given query after stripping all comments, line breaks and multiple
|
||||
spaces, and lower casing all text.
|
||||
|
||||
TODO: possible issue - the following queries will get the same id:
|
||||
1. SELECT 1 FROM table WHERE column='Value';
|
||||
2. SELECT 1 FROM table where column='value';
|
||||
"""
|
||||
sql = COMMENTS_REGEX.sub("", sql)
|
||||
sql = "".join(sql.split()).lower()
|
||||
return hashlib.md5(sql.encode('utf-8')).hexdigest()
|
||||
|
||||
|
||||
class JSONEncoder(json.JSONEncoder):
|
||||
"""Custom JSON encoding class, to handle Decimal and datetime.date instances.
|
||||
"""
|
||||
def default(self, o):
|
||||
if isinstance(o, decimal.Decimal):
|
||||
return float(o)
|
||||
|
||||
if isinstance(o, datetime.date):
|
||||
return o.isoformat()
|
||||
|
||||
super(JSONEncoder, self).default(o)
|
||||
|
||||
|
||||
class UnicodeWriter:
|
||||
"""
|
||||
A CSV writer which will write rows to CSV file "f",
|
||||
which is encoded in the given encoding.
|
||||
"""
|
||||
def __init__(self, f, dialect=csv.excel, encoding="utf-8", **kwds):
|
||||
# Redirect output to a queue
|
||||
self.queue = cStringIO.StringIO()
|
||||
self.writer = csv.writer(self.queue, dialect=dialect, **kwds)
|
||||
self.stream = f
|
||||
self.encoder = codecs.getincrementalencoder(encoding)()
|
||||
|
||||
def _encode_utf8(self, val):
|
||||
if isinstance(val, (unicode, str)):
|
||||
return val.encode('utf-8')
|
||||
|
||||
return val
|
||||
|
||||
def writerow(self, row):
|
||||
self.writer.writerow([self._encode_utf8(s) for s in row])
|
||||
# Fetch UTF-8 output from the queue ...
|
||||
data = self.queue.getvalue()
|
||||
data = data.decode("utf-8")
|
||||
# ... and reencode it into the target encoding
|
||||
data = self.encoder.encode(data)
|
||||
# write to the target stream
|
||||
self.stream.write(data)
|
||||
# empty queue
|
||||
self.queue.truncate(0)
|
||||
|
||||
def writerows(self, rows):
|
||||
for row in rows:
|
||||
self.writerow(row)
|
||||
@@ -1,255 +0,0 @@
|
||||
"""
|
||||
Worker implementation to execute incoming queries.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import uuid
|
||||
import datetime
|
||||
import time
|
||||
import signal
|
||||
import setproctitle
|
||||
import redis
|
||||
from utils import gen_query_hash
|
||||
|
||||
|
||||
class Job(object):
|
||||
HIGH_PRIORITY = 1
|
||||
LOW_PRIORITY = 2
|
||||
|
||||
WAITING = 1
|
||||
PROCESSING = 2
|
||||
DONE = 3
|
||||
FAILED = 4
|
||||
|
||||
def __init__(self, redis_connection, query, priority,
|
||||
job_id=None,
|
||||
wait_time=None, query_time=None,
|
||||
updated_at=None, status=None, error=None, query_result_id=None,
|
||||
process_id=0):
|
||||
self.redis_connection = redis_connection
|
||||
self.query = query
|
||||
self.priority = priority
|
||||
self.query_hash = gen_query_hash(self.query)
|
||||
self.query_result_id = query_result_id
|
||||
if process_id == 'None':
|
||||
self.process_id = None
|
||||
else:
|
||||
self.process_id = int(process_id)
|
||||
|
||||
if job_id is None:
|
||||
self.id = str(uuid.uuid1())
|
||||
self.new_job = True
|
||||
self.wait_time = 0
|
||||
self.query_time = 0
|
||||
self.error = None
|
||||
self.updated_at = time.time() # job_dict.get('updated_at', time.time())
|
||||
self.status = self.WAITING # int(job_dict.get('status', self.WAITING))
|
||||
else:
|
||||
self.id = job_id
|
||||
self.new_job = False
|
||||
self.error = error
|
||||
self.wait_time = wait_time
|
||||
self.query_time = query_time
|
||||
self.updated_at = updated_at
|
||||
self.status = status
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'query': self.query,
|
||||
'priority': self.priority,
|
||||
'id': self.id,
|
||||
'wait_time': self.wait_time,
|
||||
'query_time': self.query_time,
|
||||
'updated_at': self.updated_at,
|
||||
'status': self.status,
|
||||
'error': self.error,
|
||||
'query_result_id': self.query_result_id,
|
||||
'process_id': self.process_id
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _redis_key(job_id):
|
||||
return 'job:%s' % job_id
|
||||
|
||||
def cancel(self):
|
||||
# TODO: Race condition:
|
||||
# it's possible that it will be picked up by worker while processing the cancel order
|
||||
if self.is_finished():
|
||||
return
|
||||
|
||||
if self.status == self.PROCESSING:
|
||||
os.kill(self.process_id, signal.SIGINT)
|
||||
else:
|
||||
self.done(None, "Interrupted/Cancelled while running.")
|
||||
|
||||
def save(self, pipe=None):
|
||||
if not pipe:
|
||||
pipe = self.redis_connection.pipeline()
|
||||
|
||||
if self.new_job:
|
||||
pipe.set('query_hash_job:%s' % self.query_hash, self.id)
|
||||
|
||||
if self.is_finished():
|
||||
pipe.delete('query_hash_job:%s' % self.query_hash)
|
||||
|
||||
pipe.sadd('jobs_set', self.id)
|
||||
pipe.hmset(self._redis_key(self.id), self.to_dict())
|
||||
pipe.publish(self._redis_key(self.id), json.dumps(self.to_dict()))
|
||||
pipe.execute()
|
||||
|
||||
def processing(self, process_id):
|
||||
self.status = self.PROCESSING
|
||||
self.process_id = process_id
|
||||
self.wait_time = time.time() - self.updated_at
|
||||
self.updated_at = time.time()
|
||||
self.save()
|
||||
|
||||
def is_finished(self):
|
||||
return self.status in (self.FAILED, self.DONE)
|
||||
|
||||
def done(self, query_result_id, error):
|
||||
if error:
|
||||
self.status = self.FAILED
|
||||
else:
|
||||
self.status = self.DONE
|
||||
|
||||
self.query_result_id = query_result_id
|
||||
self.error = error
|
||||
self.query_time = time.time() - self.updated_at
|
||||
self.updated_at = time.time()
|
||||
self.save()
|
||||
|
||||
def __str__(self):
|
||||
return "<Job:%s,priority:%d,status:%d>" % (self.id, self.priority, self.status)
|
||||
|
||||
@classmethod
|
||||
def _load(cls, redis_connection, job_id):
|
||||
return redis_connection.hgetall(cls._redis_key(job_id))
|
||||
|
||||
@classmethod
|
||||
def load(cls, redis_connection, job_id):
|
||||
job_dict = cls._load(redis_connection, job_id)
|
||||
job = None
|
||||
if job_dict:
|
||||
job = Job(redis_connection, job_id=job_dict['id'], query=job_dict['query'].decode('utf-8'),
|
||||
priority=int(job_dict['priority']), updated_at=float(job_dict['updated_at']),
|
||||
status=int(job_dict['status']), wait_time=float(job_dict['wait_time']),
|
||||
query_time=float(job_dict['query_time']), error=job_dict['error'],
|
||||
query_result_id=job_dict['query_result_id'],
|
||||
process_id=job_dict['process_id'])
|
||||
|
||||
return job
|
||||
|
||||
|
||||
class Worker(threading.Thread):
|
||||
def __init__(self, manager, redis_connection_params, query_runner, sleep_time=0.1):
|
||||
self.manager = manager
|
||||
|
||||
self.redis_connection_params = {k: v for k, v in redis_connection_params.iteritems()
|
||||
if k in ('host', 'db', 'password', 'port')}
|
||||
self.continue_working = True
|
||||
self.query_runner = query_runner
|
||||
self.sleep_time = sleep_time
|
||||
self.child_pid = None
|
||||
self.worker_id = uuid.uuid1()
|
||||
self.status = {
|
||||
'jobs_count': 0,
|
||||
'cancelled_jobs_count': 0,
|
||||
'done_jobs_count': 0,
|
||||
'updated_at': time.time(),
|
||||
'started_at': time.time()
|
||||
}
|
||||
self._save_status()
|
||||
self.manager.redis_connection.sadd('workers', self._key)
|
||||
|
||||
super(Worker, self).__init__(name="Worker-%s" % self.worker_id)
|
||||
|
||||
def set_title(self, title=None):
|
||||
base_title = "redash worker:%s" % self.worker_id
|
||||
if title:
|
||||
full_title = "%s - %s" % (base_title, title)
|
||||
else:
|
||||
full_title = base_title
|
||||
|
||||
setproctitle.setproctitle(full_title)
|
||||
|
||||
def run(self):
|
||||
logging.info("[%s] started.", self.name)
|
||||
while self.continue_working:
|
||||
job_id = self.manager.queue.pop()
|
||||
if job_id:
|
||||
self._update_status('jobs_count')
|
||||
logging.info("[%s] Processing %s", self.name, job_id)
|
||||
self._fork_and_process(job_id)
|
||||
if self.child_pid == 0:
|
||||
return
|
||||
else:
|
||||
time.sleep(self.sleep_time)
|
||||
|
||||
def _update_status(self, counter):
|
||||
self.status['updated_at'] = time.time()
|
||||
self.status[counter] += 1
|
||||
self._save_status()
|
||||
|
||||
@property
|
||||
def _key(self):
|
||||
return 'worker:%s' % self.worker_id
|
||||
|
||||
def _save_status(self):
|
||||
self.manager.redis_connection.hmset(self._key, self.status)
|
||||
|
||||
def _fork_and_process(self, job_id):
|
||||
self.child_pid = os.fork()
|
||||
if self.child_pid == 0:
|
||||
self.set_title("processing %s" % job_id)
|
||||
self._process(job_id)
|
||||
else:
|
||||
logging.info("[%s] Waiting for pid: %d", self.name, self.child_pid)
|
||||
_, status = os.waitpid(self.child_pid, 0)
|
||||
self._update_status('done_jobs_count')
|
||||
if status > 0:
|
||||
job = Job.load(self.manager.redis_connection, job_id)
|
||||
if not job.is_finished():
|
||||
self._update_status('cancelled_jobs_count')
|
||||
logging.info("[%s] process interrupted and job %s hasn't finished; registering interruption in job",
|
||||
self.name, job_id)
|
||||
job.done(None, "Interrupted/Cancelled while running.")
|
||||
|
||||
logging.info("[%s] Finished Processing %s (pid: %d status: %d)",
|
||||
self.name, job_id, self.child_pid, status)
|
||||
|
||||
def _process(self, job_id):
|
||||
redis_connection = redis.StrictRedis(**self.redis_connection_params)
|
||||
job = Job.load(redis_connection, job_id)
|
||||
if job.is_finished():
|
||||
logging.warning("[%s][%s] tried to process finished job.", self.name, job)
|
||||
return
|
||||
|
||||
pid = os.getpid()
|
||||
job.processing(pid)
|
||||
|
||||
logging.info("[%s][%s] running query...", self.name, job.id)
|
||||
start_time = time.time()
|
||||
self.set_title("running query %s" % job_id)
|
||||
|
||||
annotated_query = "/* Pid: %s, Job Id: %s, Query hash: %s, Priority: %s */ %s" % \
|
||||
(pid, job.id, job.query_hash, job.priority, job.query)
|
||||
|
||||
# TODO: here's the part that needs to be forked, not all of the worker process...
|
||||
data, error = self.query_runner(annotated_query)
|
||||
run_time = time.time() - start_time
|
||||
logging.info("[%s][%s] query finished... data length=%s, error=%s",
|
||||
self.name, job.id, data and len(data), error)
|
||||
|
||||
# TODO: it is possible that storing the data will fail, and we will need to retry
|
||||
# while we already marked the job as done
|
||||
query_result_id = None
|
||||
if not error:
|
||||
self.set_title("storing results %s" % job_id)
|
||||
query_result_id = self.manager.store_query_result(job.query, data, run_time,
|
||||
datetime.datetime.utcnow())
|
||||
|
||||
self.set_title("marking job as done %s" % job_id)
|
||||
job.done(query_result_id, error)
|
||||
@@ -1,50 +0,0 @@
|
||||
import json
|
||||
import settings
|
||||
from data.models import *
|
||||
|
||||
# first run:
|
||||
|
||||
# CREATE TABLE "visualizations" (
|
||||
# "id" serial NOT NULL PRIMARY KEY,
|
||||
# "type" varchar(100) NOT NULL,
|
||||
# "query_id" integer NOT NULL REFERENCES "queries" ("id") DEFERRABLE INITIALLY DEFERRED,
|
||||
# "name" varchar(255) NOT NULL,
|
||||
# "description" varchar(4096),
|
||||
# "options" text NOT NULL
|
||||
# )
|
||||
# ;
|
||||
|
||||
# ALTER TABLE widgets ADD COLUMN "visualization_id" integer REFERENCES "visualizations" ("id") DEFERRABLE INITIALLY DEFERRED;
|
||||
|
||||
if __name__ == '__main__':
|
||||
print 'migrating Widgets -> Visualizations ...'
|
||||
|
||||
for query in Query.objects.filter(name__icontains="cohort"):
|
||||
vis = Visualization(query=query, name=query.name,
|
||||
description=query.description,
|
||||
type="COHORT", options="{}")
|
||||
vis.save()
|
||||
|
||||
|
||||
for widget in Widget.objects.all():
|
||||
print 'processing widget %d' % widget.id
|
||||
query = widget.query
|
||||
vis_type = widget.type.upper()
|
||||
|
||||
vis = query.visualizations.filter(type=vis_type)
|
||||
if vis:
|
||||
print 'found'
|
||||
widget.visualization = vis[0]
|
||||
widget.save()
|
||||
|
||||
else:
|
||||
options = json.loads(widget.options)
|
||||
vis_options = {"series": options} if options else {}
|
||||
vis_options = json.dumps(vis_options)
|
||||
|
||||
vis = Visualization(query=query, name=query.name,
|
||||
description=query.description,
|
||||
type=vis_type, options=vis_options)
|
||||
vis.save()
|
||||
widget.visualization = vis
|
||||
widget.save()
|
||||
@@ -1,10 +0,0 @@
|
||||
psycopg2==2.5.1
|
||||
redis==2.7.5
|
||||
tornado==3.0.2
|
||||
sqlparse==0.1.8
|
||||
Django==1.5.4
|
||||
django-db-pool==0.0.10
|
||||
qr==0.6.0
|
||||
python-dateutil==2.1
|
||||
setproctitle==1.1.8
|
||||
atfork==0.1.2
|
||||
@@ -1,381 +0,0 @@
|
||||
"""
|
||||
Tornado based API implementation for re:dash.
|
||||
|
||||
Also at the moment the Tornado server is used to serve the static assets (and the Angular.js app),
|
||||
but this is only due to configuration issues and temporary.
|
||||
|
||||
Usage:
|
||||
python server.py [--port=8888] [--debug] [--static=..]
|
||||
|
||||
port - port to listen to
|
||||
debug - enable debug mode (extensive logging, restart on code change)
|
||||
static - static assets path
|
||||
|
||||
If static option isn't specified it will be taken from settings.py.
|
||||
"""
|
||||
import csv
|
||||
import hashlib
|
||||
import json
|
||||
import numbers
|
||||
import os
|
||||
import urlparse
|
||||
import logging
|
||||
import cStringIO
|
||||
import datetime
|
||||
import dateutil.parser
|
||||
import redis
|
||||
import sqlparse
|
||||
import tornado.ioloop
|
||||
import tornado.web
|
||||
import tornado.auth
|
||||
import tornado.options
|
||||
import settings
|
||||
import time
|
||||
from data import utils
|
||||
import data
|
||||
|
||||
|
||||
class BaseHandler(tornado.web.RequestHandler):
|
||||
def initialize(self):
|
||||
self.data_manager = self.application.settings.get('data_manager', None)
|
||||
self.redis_connection = self.application.settings['redis_connection']
|
||||
|
||||
def get_current_user(self):
|
||||
user = self.get_secure_cookie("user")
|
||||
return user
|
||||
|
||||
def write_json(self, response, encode=True):
|
||||
if encode:
|
||||
response = json.dumps(response, cls=utils.JSONEncoder)
|
||||
self.set_header("Content-Type", "application/json; charset=UTF-8")
|
||||
self.write(response)
|
||||
|
||||
|
||||
class BaseAuthenticatedHandler(BaseHandler):
|
||||
@tornado.web.authenticated
|
||||
def prepare(self):
|
||||
pass
|
||||
|
||||
|
||||
class PingHandler(tornado.web.RequestHandler):
|
||||
def get(self):
|
||||
self.write("PONG")
|
||||
|
||||
|
||||
class GoogleLoginHandler(tornado.web.RequestHandler,
|
||||
tornado.auth.GoogleMixin):
|
||||
@tornado.web.asynchronous
|
||||
@tornado.gen.coroutine
|
||||
def get(self):
|
||||
if self.get_argument("openid.mode", None):
|
||||
user = yield self.get_authenticated_user()
|
||||
|
||||
if user['email'] in settings.ALLOWED_USERS or user['email'].endswith("@%s" % settings.GOOGLE_APPS_DOMAIN):
|
||||
logging.info("Authenticated: %s", user['email'])
|
||||
self.set_secure_cookie("user", user['email'])
|
||||
self.redirect("/")
|
||||
else:
|
||||
logging.error("Failed logging in with: %s", user)
|
||||
self.authenticate_redirect()
|
||||
else:
|
||||
self.authenticate_redirect()
|
||||
|
||||
|
||||
class MainHandler(BaseAuthenticatedHandler):
|
||||
def get(self, *args):
|
||||
email_md5 = hashlib.md5(self.current_user.lower()).hexdigest()
|
||||
gravatar_url = "https://www.gravatar.com/avatar/%s?s=40" % email_md5
|
||||
|
||||
user = {
|
||||
'gravatar_url': gravatar_url,
|
||||
'is_admin': self.current_user in settings.ADMINS,
|
||||
'name': self.current_user
|
||||
}
|
||||
|
||||
self.render("index.html", user=json.dumps(user), analytics=settings.ANALYTICS)
|
||||
|
||||
|
||||
class QueryFormatHandler(BaseAuthenticatedHandler):
|
||||
def post(self):
|
||||
arguments = json.loads(self.request.body)
|
||||
query = arguments.get("query", "")
|
||||
|
||||
self.write(sqlparse.format(query, reindent=True, keyword_case='upper'))
|
||||
|
||||
|
||||
class StatusHandler(BaseAuthenticatedHandler):
|
||||
def get(self):
|
||||
status = {}
|
||||
info = self.redis_connection.info()
|
||||
status['redis_used_memory'] = info['used_memory_human']
|
||||
|
||||
status['queries_count'] = data.models.Query.objects.count()
|
||||
status['query_results_count'] = data.models.QueryResult.objects.count()
|
||||
status['dashboards_count'] = data.models.Dashboard.objects.count()
|
||||
status['widgets_count'] = data.models.Widget.objects.count()
|
||||
|
||||
status['workers'] = [self.redis_connection.hgetall(w)
|
||||
for w in self.redis_connection.smembers('workers')]
|
||||
|
||||
manager_status = self.redis_connection.hgetall('manager:status')
|
||||
status['manager'] = manager_status
|
||||
status['manager']['queue_size'] = self.redis_connection.zcard('jobs')
|
||||
|
||||
self.write_json(status)
|
||||
|
||||
|
||||
class WidgetsHandler(BaseAuthenticatedHandler):
|
||||
def post(self, widget_id=None):
|
||||
widget_properties = json.loads(self.request.body)
|
||||
widget_properties['options'] = json.dumps(widget_properties['options'])
|
||||
widget = data.models.Widget(**widget_properties)
|
||||
widget.save()
|
||||
|
||||
layout = json.loads(widget.dashboard.layout)
|
||||
new_row = True
|
||||
|
||||
if len(layout) == 0 or widget.width == 2:
|
||||
layout.append([widget.id])
|
||||
elif len(layout[-1]) == 1:
|
||||
neighbour_widget = data.models.Widget.objects.get(pk=layout[-1][0])
|
||||
if neighbour_widget.width == 1:
|
||||
layout[-1].append(widget.id)
|
||||
new_row = False
|
||||
else:
|
||||
layout.append([widget.id])
|
||||
else:
|
||||
layout.append([widget.id])
|
||||
|
||||
widget.dashboard.layout = json.dumps(layout)
|
||||
widget.dashboard.save()
|
||||
|
||||
self.write_json({'widget': widget.to_dict(), 'layout': layout, 'new_row': new_row})
|
||||
|
||||
def delete(self, widget_id):
|
||||
widget_id = int(widget_id)
|
||||
widget = data.models.Widget.objects.get(pk=widget_id)
|
||||
# TODO: reposition existing ones
|
||||
layout = json.loads(widget.dashboard.layout)
|
||||
layout = map(lambda row: filter(lambda w: w != widget_id, row), layout)
|
||||
layout = filter(lambda row: len(row) > 0, layout)
|
||||
widget.dashboard.layout = json.dumps(layout)
|
||||
widget.dashboard.save()
|
||||
|
||||
widget.delete()
|
||||
|
||||
|
||||
class DashboardHandler(BaseAuthenticatedHandler):
|
||||
def get(self, dashboard_slug=None):
|
||||
if dashboard_slug:
|
||||
dashboard = data.models.Dashboard.objects.prefetch_related('widgets__visualization__query__latest_query_data').get(slug=dashboard_slug)
|
||||
self.write_json(dashboard.to_dict(with_widgets=True))
|
||||
else:
|
||||
dashboards = [d.to_dict() for d in
|
||||
data.models.Dashboard.objects.filter(is_archived=False)]
|
||||
self.write_json(dashboards)
|
||||
|
||||
def post(self, dashboard_id):
|
||||
if dashboard_id:
|
||||
dashboard_properties = json.loads(self.request.body)
|
||||
dashboard = data.models.Dashboard.objects.get(pk=dashboard_id)
|
||||
dashboard.layout = dashboard_properties['layout']
|
||||
dashboard.name = dashboard_properties['name']
|
||||
dashboard.save()
|
||||
|
||||
self.write_json(dashboard.to_dict(with_widgets=True))
|
||||
else:
|
||||
dashboard_properties = json.loads(self.request.body)
|
||||
dashboard = data.models.Dashboard(name=dashboard_properties['name'],
|
||||
user=self.current_user,
|
||||
layout='[]')
|
||||
dashboard.save()
|
||||
self.write_json(dashboard.to_dict())
|
||||
|
||||
def delete(self, dashboard_slug):
|
||||
dashboard = data.models.Dashboard.objects.get(slug=dashboard_slug)
|
||||
dashboard.is_archived = True
|
||||
dashboard.save()
|
||||
|
||||
|
||||
class QueriesHandler(BaseAuthenticatedHandler):
|
||||
def post(self, id=None):
|
||||
query_def = json.loads(self.request.body)
|
||||
if 'created_at' in query_def:
|
||||
query_def['created_at'] = dateutil.parser.parse(query_def['created_at'])
|
||||
|
||||
query_def.pop('latest_query_data', None)
|
||||
query_def.pop('visualizations', None)
|
||||
|
||||
if id:
|
||||
query = data.models.Query(**query_def)
|
||||
fields = query_def.keys()
|
||||
fields.remove('id')
|
||||
query.save(update_fields=fields)
|
||||
else:
|
||||
query_def['user'] = self.current_user
|
||||
query = data.models.Query(**query_def)
|
||||
query.save()
|
||||
|
||||
self.write_json(query.to_dict(with_result=False))
|
||||
|
||||
def get(self, id=None):
|
||||
if id:
|
||||
q = data.models.Query.objects.get(pk=id)
|
||||
if q:
|
||||
self.write_json(q.to_dict(with_visualizations=True))
|
||||
else:
|
||||
self.send_error(404)
|
||||
else:
|
||||
self.write_json([q.to_dict(with_result=False, with_stats=True) for q in data.models.Query.all_queries()])
|
||||
|
||||
|
||||
class QueryResultsHandler(BaseAuthenticatedHandler):
|
||||
def get(self, query_result_id):
|
||||
query_result = self.data_manager.get_query_result_by_id(query_result_id)
|
||||
if query_result:
|
||||
self.write_json({'query_result': query_result.to_dict(parse_data=True)})
|
||||
else:
|
||||
self.send_error(404)
|
||||
|
||||
def post(self, _):
|
||||
params = json.loads(self.request.body)
|
||||
|
||||
if params['ttl'] == 0:
|
||||
query_result = None
|
||||
else:
|
||||
query_result = self.data_manager.get_query_result(params['query'], int(params['ttl']))
|
||||
|
||||
if query_result:
|
||||
self.write_json({'query_result': query_result.to_dict(parse_data=True)})
|
||||
else:
|
||||
job = self.data_manager.add_job(params['query'], data.Job.HIGH_PRIORITY)
|
||||
self.write({'job': job.to_dict()})
|
||||
|
||||
|
||||
class VisualizationHandler(BaseAuthenticatedHandler):
|
||||
def get(self, id):
|
||||
pass
|
||||
|
||||
def post(self, id=None):
|
||||
kwargs = json.loads(self.request.body)
|
||||
kwargs['options'] = json.dumps(kwargs['options'])
|
||||
|
||||
if id:
|
||||
vis = data.models.Visualization(**kwargs)
|
||||
fields = kwargs.keys()
|
||||
fields.remove('id')
|
||||
vis.save(update_fields=fields)
|
||||
else:
|
||||
vis = data.models.Visualization(**kwargs)
|
||||
vis.save()
|
||||
|
||||
self.write_json(vis.to_dict(with_query=False))
|
||||
|
||||
def delete(self, id):
|
||||
vis = data.models.Visualization.objects.get(pk=id)
|
||||
vis.delete()
|
||||
|
||||
|
||||
class CsvQueryResultsHandler(BaseAuthenticatedHandler):
|
||||
def get_current_user(self):
|
||||
user = super(CsvQueryResultsHandler, self).get_current_user()
|
||||
if not user:
|
||||
api_key = self.get_argument("api_key", None)
|
||||
query = data.models.Query.objects.get(pk=self.path_args[0])
|
||||
|
||||
if query.api_key and query.api_key == api_key:
|
||||
user = "API-Key=%s" % api_key
|
||||
|
||||
return user
|
||||
|
||||
def get(self, query_id, result_id=None):
|
||||
if not result_id:
|
||||
query = data.models.Query.objects.get(pk=query_id)
|
||||
if query:
|
||||
result_id = query.latest_query_data_id
|
||||
|
||||
query_result = result_id and self.data_manager.get_query_result_by_id(result_id)
|
||||
if query_result:
|
||||
self.set_header("Content-Type", "text/csv; charset=UTF-8")
|
||||
s = cStringIO.StringIO()
|
||||
|
||||
query_data = json.loads(query_result.data)
|
||||
writer = csv.DictWriter(s, fieldnames=[col['name'] for col in query_data['columns']])
|
||||
writer.writer = utils.UnicodeWriter(s)
|
||||
writer.writeheader()
|
||||
for row in query_data['rows']:
|
||||
for k, v in row.iteritems():
|
||||
if isinstance(v, numbers.Number) and (v > 1000 * 1000 * 1000 * 100):
|
||||
row[k] = datetime.datetime.fromtimestamp(v/1000.0)
|
||||
|
||||
writer.writerow(row)
|
||||
|
||||
self.write(s.getvalue())
|
||||
else:
|
||||
self.send_error(404)
|
||||
|
||||
|
||||
class JobsHandler(BaseAuthenticatedHandler):
|
||||
def get(self, job_id=None):
|
||||
if job_id:
|
||||
# TODO: if finished, include the query result
|
||||
job = data.Job.load(self.data_manager.redis_connection, job_id)
|
||||
self.write({'job': job.to_dict()})
|
||||
else:
|
||||
raise NotImplemented
|
||||
|
||||
def delete(self, job_id):
|
||||
job = data.Job.load(self.data_manager.redis_connection, job_id)
|
||||
job.cancel()
|
||||
|
||||
|
||||
def get_application(static_path, is_debug, redis_connection, data_manager):
|
||||
return tornado.web.Application([(r"/", MainHandler),
|
||||
(r"/ping", PingHandler),
|
||||
(r"/api/queries/([0-9]*)/results(?:/([0-9]*))?.csv", CsvQueryResultsHandler),
|
||||
(r"/api/queries/format", QueryFormatHandler),
|
||||
(r"/api/queries(?:/([0-9]*))?", QueriesHandler),
|
||||
(r"/api/query_results(?:/([0-9]*))?", QueryResultsHandler),
|
||||
(r"/api/jobs/(.*)", JobsHandler),
|
||||
(r"/api/visualizations(?:/([0-9]*))?", VisualizationHandler),
|
||||
(r"/api/widgets(?:/([0-9]*))?", WidgetsHandler),
|
||||
(r"/api/dashboards(?:/(.*))?", DashboardHandler),
|
||||
(r"/admin/(.*)", MainHandler),
|
||||
(r"/dashboard/(.*)", MainHandler),
|
||||
(r"/queries(.*)", MainHandler),
|
||||
(r"/login", GoogleLoginHandler),
|
||||
(r"/status.json", StatusHandler),
|
||||
(r"/(.*)", tornado.web.StaticFileHandler,
|
||||
{"path": static_path})],
|
||||
template_path=static_path,
|
||||
static_path=static_path,
|
||||
debug=is_debug,
|
||||
login_url="/login",
|
||||
cookie_secret=settings.COOKIE_SECRET,
|
||||
redis_connection=redis_connection,
|
||||
data_manager=data_manager)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
tornado.options.define("port", default=8888, type=int)
|
||||
tornado.options.define("debug", default=False, type=bool)
|
||||
tornado.options.define("static", default=settings.STATIC_ASSETS_PATH, type=str)
|
||||
|
||||
tornado.options.parse_command_line()
|
||||
|
||||
root_path = os.path.dirname(__file__)
|
||||
static_path = os.path.abspath(os.path.join(root_path, tornado.options.options.static))
|
||||
|
||||
url = urlparse.urlparse(settings.REDIS_URL)
|
||||
redis_connection = redis.StrictRedis(host=url.hostname, port=url.port, db=0, password=url.password)
|
||||
data_manager = data.Manager(redis_connection, settings.INTERNAL_DB_CONNECTION_STRING,
|
||||
settings.MAX_CONNECTIONS)
|
||||
|
||||
logging.info("re:dash web server stating on port: %d...", tornado.options.options.port)
|
||||
logging.info("UI assets path: %s...", static_path)
|
||||
|
||||
application = get_application(static_path, tornado.options.options.debug,
|
||||
redis_connection, data_manager)
|
||||
|
||||
application.listen(tornado.options.options.port)
|
||||
tornado.ioloop.IOLoop.instance().start()
|
||||
@@ -1,41 +0,0 @@
|
||||
"""
|
||||
Example settings module. You should make your own copy as settings.py and enter the real settings.
|
||||
"""
|
||||
|
||||
import django.conf
|
||||
|
||||
REDIS_URL = "redis://localhost:6379"
|
||||
|
||||
# Either "pg" or "mysql"
|
||||
CONNECTION_ADAPTER = "mysql"
|
||||
# Connection string for the database that is used to run queries against
|
||||
# -- example mysql CONNECTION_STRING = "Server=;User=;Pwd=;Database="
|
||||
# -- example pg CONNECTION_STRING = "user= password= host= port=5439 dbname="
|
||||
CONNECTION_STRING = "user= password= host= port=5439 dbname="
|
||||
# Connection string for the operational databases (where we store the queries, results, etc)
|
||||
INTERNAL_DB_CONNECTION_STRING = "dbname=postgres"
|
||||
# Google Apps domain to allow access from; any user with email in this Google Apps will be allowed
|
||||
# access
|
||||
GOOGLE_APPS_DOMAIN = ""
|
||||
# Email addresses of specific users not from the above set Google Apps Domain, that you want to
|
||||
# allow access to re:dash
|
||||
ALLOWED_USERS = []
|
||||
# Email addresses of admin users
|
||||
ADMINS = []
|
||||
STATIC_ASSETS_PATH = "../rd_ui/dist/"
|
||||
WORKERS_COUNT = 2
|
||||
MAX_CONNECTIONS = 3
|
||||
COOKIE_SECRET = "c292a0a3aa32397cdb050e233733900f"
|
||||
LOG_LEVEL = "INFO"
|
||||
ANALYTICS = ""
|
||||
|
||||
# Configuration of the operational database for the Django models
|
||||
django.conf.settings.configure(DATABASES = { 'default': {
|
||||
'ENGINE': 'dbpool.db.backends.postgresql_psycopg2',
|
||||
'OPTIONS': {'MAX_CONNS': 10, 'MIN_CONNS': 1},
|
||||
'NAME': 'postgres',
|
||||
'USER': '',
|
||||
'PASSWORD': '',
|
||||
'HOST': '',
|
||||
'PORT': '',
|
||||
},}, TIME_ZONE = 'UTC')
|
||||
@@ -1,6 +1,5 @@
|
||||
language: node_js
|
||||
node_js:
|
||||
- '0.8'
|
||||
- '0.10'
|
||||
before_script:
|
||||
- 'npm install -g bower grunt-cli'
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
// Generated on 2013-08-25 using generator-angular 0.4.0
|
||||
// Generated on 2014-07-30 using generator-angular 0.9.2
|
||||
'use strict';
|
||||
var LIVERELOAD_PORT = 35729;
|
||||
var lrSnippet = require('connect-livereload')({ port: LIVERELOAD_PORT });
|
||||
var mountFolder = function (connect, dir) {
|
||||
return connect.static(require('path').resolve(dir));
|
||||
};
|
||||
|
||||
// # Globbing
|
||||
// for performance reasons we're only matching one level down:
|
||||
@@ -13,48 +8,148 @@ var mountFolder = function (connect, dir) {
|
||||
// 'test/spec/**/*.js'
|
||||
|
||||
module.exports = function (grunt) {
|
||||
|
||||
// Load grunt tasks automatically
|
||||
require('load-grunt-tasks')(grunt);
|
||||
|
||||
// Time how long tasks take. Can help when optimizing build times
|
||||
require('time-grunt')(grunt);
|
||||
|
||||
// configurable paths
|
||||
var yeomanConfig = {
|
||||
app: 'app',
|
||||
// Configurable paths for the application
|
||||
var appConfig = {
|
||||
app: require('./bower.json').appPath || 'app',
|
||||
dist: 'dist'
|
||||
};
|
||||
|
||||
try {
|
||||
yeomanConfig.app = require('./bower.json').appPath || yeomanConfig.app;
|
||||
} catch (e) {}
|
||||
|
||||
// Define the configuration for all the tasks
|
||||
grunt.initConfig({
|
||||
yeoman: yeomanConfig,
|
||||
|
||||
// Project settings
|
||||
yeoman: appConfig,
|
||||
|
||||
// Watches files for changes and runs tasks based on the changed files
|
||||
watch: {
|
||||
coffee: {
|
||||
files: ['<%= yeoman.app %>/scripts/{,*/}*.coffee'],
|
||||
tasks: ['coffee:dist']
|
||||
bower: {
|
||||
files: ['bower.json'],
|
||||
tasks: ['wiredep']
|
||||
},
|
||||
coffeeTest: {
|
||||
files: ['test/spec/{,*/}*.coffee'],
|
||||
tasks: ['coffee:test']
|
||||
js: {
|
||||
files: ['<%= yeoman.app %>/scripts/{,*/}*.js'],
|
||||
tasks: ['newer:jshint:all'],
|
||||
options: {
|
||||
livereload: '<%= connect.options.livereload %>'
|
||||
}
|
||||
},
|
||||
jsTest: {
|
||||
files: ['test/spec/{,*/}*.js'],
|
||||
tasks: ['newer:jshint:test', 'karma']
|
||||
},
|
||||
styles: {
|
||||
files: ['<%= yeoman.app %>/styles/{,*/}*.css'],
|
||||
tasks: ['copy:styles', 'autoprefixer']
|
||||
tasks: ['newer:copy:styles', 'autoprefixer']
|
||||
},
|
||||
gruntfile: {
|
||||
files: ['Gruntfile.js']
|
||||
},
|
||||
livereload: {
|
||||
options: {
|
||||
livereload: LIVERELOAD_PORT
|
||||
livereload: '<%= connect.options.livereload %>'
|
||||
},
|
||||
files: [
|
||||
'<%= yeoman.app %>/{,*/}*.html',
|
||||
'.tmp/styles/{,*/}*.css',
|
||||
'{.tmp,<%= yeoman.app %>}/scripts/{,*/}*.js',
|
||||
'<%= yeoman.app %>/images/{,*/}*.{png,jpg,jpeg,gif,webp,svg}'
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
// The actual grunt server settings
|
||||
connect: {
|
||||
options: {
|
||||
port: 9000,
|
||||
// Change this to '0.0.0.0' to access the server from outside.
|
||||
hostname: 'localhost',
|
||||
livereload: 35729
|
||||
},
|
||||
livereload: {
|
||||
options: {
|
||||
open: true,
|
||||
middleware: function (connect) {
|
||||
return [
|
||||
connect.static('.tmp'),
|
||||
connect().use(
|
||||
'/bower_components',
|
||||
connect.static('./bower_components')
|
||||
),
|
||||
connect.static(appConfig.app)
|
||||
];
|
||||
}
|
||||
}
|
||||
},
|
||||
test: {
|
||||
options: {
|
||||
port: 9001,
|
||||
middleware: function (connect) {
|
||||
return [
|
||||
connect.static('.tmp'),
|
||||
connect.static('test'),
|
||||
connect().use(
|
||||
'/bower_components',
|
||||
connect.static('./bower_components')
|
||||
),
|
||||
connect.static(appConfig.app)
|
||||
];
|
||||
}
|
||||
}
|
||||
},
|
||||
dist: {
|
||||
options: {
|
||||
open: true,
|
||||
base: '<%= yeoman.dist %>'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Make sure code styles are up to par and there are no obvious mistakes
|
||||
jshint: {
|
||||
options: {
|
||||
jshintrc: '.jshintrc',
|
||||
reporter: require('jshint-stylish')
|
||||
},
|
||||
all: {
|
||||
src: [
|
||||
'Gruntfile.js',
|
||||
'<%= yeoman.app %>/scripts/{,*/}*.js'
|
||||
]
|
||||
},
|
||||
test: {
|
||||
options: {
|
||||
jshintrc: 'test/.jshintrc'
|
||||
},
|
||||
src: ['test/spec/{,*/}*.js']
|
||||
}
|
||||
},
|
||||
|
||||
// Empties folders to start fresh
|
||||
clean: {
|
||||
dist: {
|
||||
files: [{
|
||||
dot: true,
|
||||
src: [
|
||||
'.tmp',
|
||||
'<%= yeoman.dist %>/{,*/}*',
|
||||
'!<%= yeoman.dist %>/.git*'
|
||||
]
|
||||
}]
|
||||
},
|
||||
server: '.tmp'
|
||||
},
|
||||
|
||||
// Add vendor prefixed styles
|
||||
autoprefixer: {
|
||||
options: ['last 1 version'],
|
||||
options: {
|
||||
browsers: ['last 1 version']
|
||||
},
|
||||
dist: {
|
||||
files: [{
|
||||
expand: true,
|
||||
@@ -64,134 +159,94 @@ module.exports = function (grunt) {
|
||||
}]
|
||||
}
|
||||
},
|
||||
connect: {
|
||||
|
||||
// Automatically inject Bower components into the app
|
||||
wiredep: {
|
||||
options: {
|
||||
port: 9000,
|
||||
// Change this to '0.0.0.0' to access the server from outside.
|
||||
hostname: 'localhost'
|
||||
},
|
||||
livereload: {
|
||||
options: {
|
||||
middleware: function (connect) {
|
||||
return [
|
||||
lrSnippet,
|
||||
mountFolder(connect, '.tmp'),
|
||||
mountFolder(connect, yeomanConfig.app)
|
||||
];
|
||||
}
|
||||
}
|
||||
},
|
||||
test: {
|
||||
options: {
|
||||
middleware: function (connect) {
|
||||
return [
|
||||
mountFolder(connect, '.tmp'),
|
||||
mountFolder(connect, 'test')
|
||||
];
|
||||
}
|
||||
}
|
||||
},
|
||||
dist: {
|
||||
options: {
|
||||
middleware: function (connect) {
|
||||
return [
|
||||
mountFolder(connect, yeomanConfig.dist)
|
||||
];
|
||||
}
|
||||
}
|
||||
app: {
|
||||
src: ['<%= yeoman.app %>/index.html'],
|
||||
ignorePath: /\.\.\//
|
||||
}
|
||||
},
|
||||
open: {
|
||||
server: {
|
||||
url: 'http://localhost:<%= connect.options.port %>'
|
||||
}
|
||||
},
|
||||
clean: {
|
||||
dist: {
|
||||
files: [{
|
||||
dot: true,
|
||||
src: [
|
||||
'.tmp',
|
||||
'<%= yeoman.dist %>/*',
|
||||
'!<%= yeoman.dist %>/.git*'
|
||||
]
|
||||
}]
|
||||
},
|
||||
server: '.tmp'
|
||||
},
|
||||
jshint: {
|
||||
options: {
|
||||
jshintrc: '.jshintrc'
|
||||
},
|
||||
all: [
|
||||
'Gruntfile.js',
|
||||
'<%= yeoman.app %>/scripts/{,*/}*.js'
|
||||
]
|
||||
},
|
||||
coffee: {
|
||||
options: {
|
||||
sourceMap: true,
|
||||
sourceRoot: ''
|
||||
},
|
||||
dist: {
|
||||
files: [{
|
||||
expand: true,
|
||||
cwd: '<%= yeoman.app %>/scripts',
|
||||
src: '{,*/}*.coffee',
|
||||
dest: '.tmp/scripts',
|
||||
ext: '.js'
|
||||
}]
|
||||
},
|
||||
test: {
|
||||
files: [{
|
||||
expand: true,
|
||||
cwd: 'test/spec',
|
||||
src: '{,*/}*.coffee',
|
||||
dest: '.tmp/spec',
|
||||
ext: '.js'
|
||||
}]
|
||||
}
|
||||
},
|
||||
// not used since Uglify task does concat,
|
||||
// but still available if needed
|
||||
/*concat: {
|
||||
dist: {}
|
||||
},*/
|
||||
rev: {
|
||||
dist: {
|
||||
files: {
|
||||
src: [
|
||||
'<%= yeoman.dist %>/scripts/{,*/}*.js',
|
||||
'<%= yeoman.dist %>/styles/{,*/}*.css',
|
||||
'<%= yeoman.dist %>/images/{,*/}*.{png,jpg,jpeg,gif,webp,svg}',
|
||||
'<%= yeoman.dist %>/styles/fonts/*'
|
||||
]
|
||||
}
|
||||
|
||||
// Renames files for browser caching purposes
|
||||
filerev: {
|
||||
dist: {
|
||||
src: [
|
||||
'<%= yeoman.dist %>/scripts/{,*/}*.js',
|
||||
'<%= yeoman.dist %>/styles/{,*/}*.css',
|
||||
'<%= yeoman.dist %>/images/{,*/}*.{png,jpg,jpeg,gif,webp,svg}',
|
||||
'<%= yeoman.dist %>/styles/fonts/*'
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
// Reads HTML for usemin blocks to enable smart builds that automatically
|
||||
// concat, minify and revision files. Creates configurations in memory so
|
||||
// additional tasks can operate on them
|
||||
useminPrepare: {
|
||||
html: '<%= yeoman.app %>/index.html',
|
||||
html: ['<%= yeoman.app %>/index.html', '<%= yeoman.app %>/login.html'],
|
||||
options: {
|
||||
dest: '<%= yeoman.dist %>'
|
||||
dest: '<%= yeoman.dist %>',
|
||||
flow: {
|
||||
html: {
|
||||
steps: {
|
||||
js: ['concat', 'uglifyjs'],
|
||||
css: ['cssmin']
|
||||
},
|
||||
post: {}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Performs rewrites based on filerev and the useminPrepare configuration
|
||||
usemin: {
|
||||
html: ['<%= yeoman.dist %>/{,*/}*.html'],
|
||||
css: ['<%= yeoman.dist %>/styles/{,*/}*.css'],
|
||||
options: {
|
||||
dirs: ['<%= yeoman.dist %>']
|
||||
assetsDirs: ['<%= yeoman.dist %>','<%= yeoman.dist %>/images']
|
||||
}
|
||||
},
|
||||
|
||||
// The following *-min tasks will produce minified files in the dist folder
|
||||
// By default, your `index.html`'s <!-- Usemin block --> will take care of
|
||||
// minification. These next options are pre-configured if you do not wish
|
||||
// to use the Usemin blocks.
|
||||
// cssmin: {
|
||||
// dist: {
|
||||
// files: {
|
||||
// '<%= yeoman.dist %>/styles/main.css': [
|
||||
// '.tmp/styles/{,*/}*.css'
|
||||
// ]
|
||||
// }
|
||||
// }
|
||||
// },
|
||||
// uglify: {
|
||||
// dist: {
|
||||
// files: {
|
||||
// '<%= yeoman.dist %>/scripts/scripts.js': [
|
||||
// '<%= yeoman.dist %>/scripts/scripts.js'
|
||||
// ]
|
||||
// }
|
||||
// }
|
||||
// },
|
||||
// concat: {
|
||||
// dist: {}
|
||||
// },
|
||||
|
||||
imagemin: {
|
||||
dist: {
|
||||
files: [{
|
||||
expand: true,
|
||||
cwd: '<%= yeoman.app %>/images',
|
||||
src: '{,*/}*.{png,jpg,jpeg}',
|
||||
src: '{,*/}*.{png,jpg,jpeg,gif}',
|
||||
dest: '<%= yeoman.dist %>/images'
|
||||
}]
|
||||
}
|
||||
},
|
||||
|
||||
svgmin: {
|
||||
dist: {
|
||||
files: [{
|
||||
@@ -202,41 +257,47 @@ module.exports = function (grunt) {
|
||||
}]
|
||||
}
|
||||
},
|
||||
cssmin: {
|
||||
// By default, your `index.html` <!-- Usemin Block --> will take care of
|
||||
// minification. This option is pre-configured if you do not wish to use
|
||||
// Usemin blocks.
|
||||
// dist: {
|
||||
// files: {
|
||||
// '<%= yeoman.dist %>/styles/main.css': [
|
||||
// '.tmp/styles/{,*/}*.css',
|
||||
// '<%= yeoman.app %>/styles/{,*/}*.css'
|
||||
// ]
|
||||
// }
|
||||
// }
|
||||
},
|
||||
|
||||
htmlmin: {
|
||||
dist: {
|
||||
options: {
|
||||
/*removeCommentsFromCDATA: true,
|
||||
// https://github.com/yeoman/grunt-usemin/issues/44
|
||||
//collapseWhitespace: true,
|
||||
collapseWhitespace: true,
|
||||
conservativeCollapse: true,
|
||||
collapseBooleanAttributes: true,
|
||||
removeAttributeQuotes: true,
|
||||
removeRedundantAttributes: true,
|
||||
useShortDoctype: true,
|
||||
removeEmptyAttributes: true,
|
||||
removeOptionalTags: true*/
|
||||
removeCommentsFromCDATA: true,
|
||||
removeOptionalTags: true
|
||||
},
|
||||
files: [{
|
||||
expand: true,
|
||||
cwd: '<%= yeoman.app %>',
|
||||
src: ['*.html', 'views/*.html'],
|
||||
cwd: '<%= yeoman.dist %>',
|
||||
src: ['*.html', 'views/{,*/}*.html'],
|
||||
dest: '<%= yeoman.dist %>'
|
||||
}]
|
||||
}
|
||||
},
|
||||
// Put files not handled in other tasks here
|
||||
|
||||
// ngmin tries to make the code safe for minification automatically by
|
||||
// using the Angular long form for dependency injection. It doesn't work on
|
||||
// things like resolve or inject so those have to be done manually.
|
||||
ngmin: {
|
||||
dist: {
|
||||
files: [{
|
||||
expand: true,
|
||||
cwd: '.tmp/concat/scripts',
|
||||
src: '*.js',
|
||||
dest: '.tmp/concat/scripts'
|
||||
}]
|
||||
}
|
||||
},
|
||||
|
||||
// Replace Google CDN references
|
||||
cdnify: {
|
||||
dist: {
|
||||
html: ['<%= yeoman.dist %>/*.html']
|
||||
}
|
||||
},
|
||||
|
||||
// Copies remaining files to places other tasks can use
|
||||
copy: {
|
||||
dist: {
|
||||
files: [{
|
||||
@@ -247,17 +308,21 @@ module.exports = function (grunt) {
|
||||
src: [
|
||||
'*.{ico,png,txt}',
|
||||
'.htaccess',
|
||||
'bower_components/**/*',
|
||||
'images/{,*/}*.{gif,webp}',
|
||||
'*.html',
|
||||
'views/{,*/}*.html',
|
||||
'images/{,*/}*.{webp}',
|
||||
'fonts/*'
|
||||
]
|
||||
}, {
|
||||
expand: true,
|
||||
cwd: '.tmp/images',
|
||||
dest: '<%= yeoman.dist %>/images',
|
||||
src: [
|
||||
'generated/*'
|
||||
]
|
||||
src: ['generated/*']
|
||||
}, {
|
||||
expand: true,
|
||||
cwd: 'bower_components/bootstrap/dist',
|
||||
src: 'fonts/*',
|
||||
dest: '<%= yeoman.dist %>'
|
||||
}]
|
||||
},
|
||||
styles: {
|
||||
@@ -267,70 +332,52 @@ module.exports = function (grunt) {
|
||||
src: '{,*/}*.css'
|
||||
}
|
||||
},
|
||||
|
||||
// Run some tasks in parallel to speed up the build process
|
||||
concurrent: {
|
||||
server: [
|
||||
'coffee:dist',
|
||||
'copy:styles'
|
||||
],
|
||||
test: [
|
||||
'coffee',
|
||||
'copy:styles'
|
||||
],
|
||||
dist: [
|
||||
'coffee',
|
||||
'copy:styles',
|
||||
'imagemin',
|
||||
'svgmin',
|
||||
'htmlmin'
|
||||
'svgmin'
|
||||
]
|
||||
},
|
||||
|
||||
// Test settings
|
||||
karma: {
|
||||
unit: {
|
||||
configFile: 'karma.conf.js',
|
||||
configFile: 'test/karma.conf.js',
|
||||
singleRun: true
|
||||
}
|
||||
},
|
||||
cdnify: {
|
||||
dist: {
|
||||
html: ['<%= yeoman.dist %>/*.html']
|
||||
}
|
||||
},
|
||||
ngmin: {
|
||||
dist: {
|
||||
files: [{
|
||||
expand: true,
|
||||
cwd: '<%= yeoman.dist %>/scripts',
|
||||
src: '*.js',
|
||||
dest: '<%= yeoman.dist %>/scripts'
|
||||
}]
|
||||
}
|
||||
},
|
||||
uglify: {
|
||||
dist: {
|
||||
files: {
|
||||
'<%= yeoman.dist %>/scripts/scripts.js': [
|
||||
'<%= yeoman.dist %>/scripts/scripts.js'
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
grunt.registerTask('server', function (target) {
|
||||
|
||||
grunt.registerTask('serve', 'Compile then start a connect web server', function (target) {
|
||||
if (target === 'dist') {
|
||||
return grunt.task.run(['build', 'open', 'connect:dist:keepalive']);
|
||||
return grunt.task.run(['build', 'connect:dist:keepalive']);
|
||||
}
|
||||
|
||||
grunt.task.run([
|
||||
'clean:server',
|
||||
'wiredep',
|
||||
'concurrent:server',
|
||||
'autoprefixer',
|
||||
'connect:livereload',
|
||||
'open',
|
||||
'watch'
|
||||
]);
|
||||
});
|
||||
|
||||
grunt.registerTask('server', 'DEPRECATED TASK. Use the "serve" task instead', function (target) {
|
||||
grunt.log.warn('The `server` task has been deprecated. Use `grunt serve` to start a server.');
|
||||
grunt.task.run(['serve:' + target]);
|
||||
});
|
||||
|
||||
grunt.registerTask('test', [
|
||||
'clean:server',
|
||||
'concurrent:test',
|
||||
@@ -341,21 +388,23 @@ module.exports = function (grunt) {
|
||||
|
||||
grunt.registerTask('build', [
|
||||
'clean:dist',
|
||||
'wiredep',
|
||||
'useminPrepare',
|
||||
'concurrent:dist',
|
||||
'autoprefixer',
|
||||
'concat',
|
||||
'ngmin',
|
||||
'copy:dist',
|
||||
'cdnify',
|
||||
'ngmin',
|
||||
'cssmin',
|
||||
'uglify',
|
||||
'rev',
|
||||
'usemin'
|
||||
'filerev',
|
||||
'usemin',
|
||||
'htmlmin'
|
||||
]);
|
||||
|
||||
grunt.registerTask('default', [
|
||||
'jshint',
|
||||
'newer:jshint',
|
||||
'test',
|
||||
'build'
|
||||
]);
|
||||
|
||||
BIN
rd_ui/app/favicon.ico
Normal file
BIN
rd_ui/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
BIN
rd_ui/app/google_login.png
Normal file
BIN
rd_ui/app/google_login.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
@@ -4,7 +4,7 @@
|
||||
<!--[if IE 8]> <html class="no-js lt-ie9" ng-app="redash" ng-controller='MainCtrl'> <![endif]-->
|
||||
<!--[if gt IE 8]><!--> <html class="no-js" ng-app="redash" ng-controller='MainCtrl'> <!--<![endif]-->
|
||||
<head>
|
||||
<title ng-bind="'re:dash | ' + pageTitle"></title>
|
||||
<title ng-bind="'{{name}} | ' + pageTitle"></title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
|
||||
@@ -12,8 +12,11 @@
|
||||
<link rel="stylesheet" href="/bower_components/bootstrap/dist/css/bootstrap.css">
|
||||
<link rel="stylesheet" href="/bower_components/codemirror/lib/codemirror.css">
|
||||
<link rel="stylesheet" href="/bower_components/gridster/dist/jquery.gridster.css">
|
||||
<link rel="stylesheet" href="/bower_components/pivottable/examples/pivot.css">
|
||||
<link rel="stylesheet" href="/bower_components/pivottable/dist/pivot.css">
|
||||
<link rel="stylesheet" href="/bower_components/cornelius/src/cornelius.css">
|
||||
<link rel="stylesheet" href="/bower_components/select2/select2.css">
|
||||
<link rel="stylesheet" href="/bower_components/angular-ui-select/dist/select.css">
|
||||
<link rel="stylesheet" href="/bower_components/pace/themes/pace-theme-minimal.css">
|
||||
<link rel="stylesheet" href="/styles/redash.css">
|
||||
<!-- endbuild -->
|
||||
</head>
|
||||
@@ -29,12 +32,13 @@
|
||||
<span class="icon-bar"></span>
|
||||
<span class="icon-bar"></span>
|
||||
</button>
|
||||
<a class="navbar-brand" href="/"><strong>re:dash</strong></a>
|
||||
<a class="navbar-brand" href="/"><strong>{{name}}</strong></a>
|
||||
</div>
|
||||
{% raw %}
|
||||
<div class="collapse navbar-collapse navbar-ex1-collapse">
|
||||
<ul class="nav navbar-nav">
|
||||
<li class="active" ng-show="pageTitle"><a class="page-title" ng-bind="pageTitle"></a></li>
|
||||
<li class="dropdown">
|
||||
<li class="dropdown" ng-show="groupedDashboards.length > 0 || otherDashboards.length > 0 || currentUser.hasPermission('create_dashboard')">
|
||||
<a href="#" class="dropdown-toggle" data-toggle="dropdown"><span class="glyphicon glyphicon-th-large"></span> <b class="caret"></b></a>
|
||||
<ul class="dropdown-menu">
|
||||
<span ng-repeat="(name, group) in groupedDashboards">
|
||||
@@ -42,32 +46,42 @@
|
||||
<a href="#" ng-bind="name"></a>
|
||||
<ul class="dropdown-menu">
|
||||
<li ng-repeat="dashboard in group" role="presentation">
|
||||
<a role="menu-item" ng-href="/dashboard/{{!dashboard.slug}}" ng-bind="dashboard.name"></a>
|
||||
<a role="menu-item" ng-href="/dashboard/{{dashboard.slug}}" ng-bind="dashboard.name"></a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</span>
|
||||
<li ng-repeat="dashboard in otherDashboards">
|
||||
<a role="menu-item" ng-href="/dashboard/{{!dashboard.slug}}" ng-bind="dashboard.name"></a>
|
||||
<a role="menu-item" ng-href="/dashboard/{{dashboard.slug}}" ng-bind="dashboard.name"></a>
|
||||
</li>
|
||||
<li class="divider"></li>
|
||||
<li><a data-toggle="modal" href="#new_dashboard_dialog">New Dashboard</a></li>
|
||||
<li class="divider" ng-show="currentUser.hasPermission('create_dashboard') && (groupedDashboards.length > 0 || otherDashboards.length > 0)"></li>
|
||||
<li><a data-toggle="modal" href="#new_dashboard_dialog" ng-show="currentUser.hasPermission('create_dashboard')">New Dashboard</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="dropdown">
|
||||
<li class="dropdown" ng-show="currentUser.hasPermission('view_query')">
|
||||
<a href="#" class="dropdown-toggle" data-toggle="dropdown">Queries <b class="caret"></b></a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a href="/queries/new">New Query</a></li>
|
||||
<li ng-show="currentUser.hasPermission('create_query')"><a href="/queries/new">New Query</a></li>
|
||||
<li><a href="/queries">Queries</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
<form class="navbar-form navbar-left" role="search" ng-submit="searchQueries()">
|
||||
<div class="form-group">
|
||||
<input type="text" ng-model="term" class="form-control" placeholder="Search queries...">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-default"><span class="glyphicon glyphicon-search"></span></button>
|
||||
</form>
|
||||
<ul class="nav navbar-nav navbar-right">
|
||||
<p class="navbar-text avatar">
|
||||
<img ng-src="{{!currentUser.gravatar_url}}" class="img-circle" alt="{{!currentUser.name}}" width="40" height="40"/>
|
||||
<p class="navbar-text avatar" ng-show="currentUser.id" ng-cloak>
|
||||
<img ng-src="{{currentUser.gravatar_url}}" class="img-circle" alt="{{currentUser.name}}"/>
|
||||
<a target="_self" href="/logout" id="logout" title="Logout">
|
||||
<span class="glyphicon glyphicon-log-out"></span>
|
||||
</a>
|
||||
</p>
|
||||
</ul>
|
||||
</div>
|
||||
{% endraw %}
|
||||
|
||||
</div>
|
||||
</nav>
|
||||
@@ -95,38 +109,63 @@
|
||||
<script src="/bower_components/angular-ui-codemirror/ui-codemirror.js"></script>
|
||||
<script src="/bower_components/highcharts/highcharts.js"></script>
|
||||
<script src="/bower_components/highcharts/modules/exporting.js"></script>
|
||||
<script src="/scripts/ng-highchart.js"></script>
|
||||
<script src="/scripts/smart-table.js"></script>
|
||||
<script src="/scripts/ui-bootstrap-tpls-0.5.0.min.js"></script>
|
||||
<script src="/bower_components/gridster/dist/jquery.gridster.js"></script>
|
||||
<script src="/bower_components/angular-growl/build/angular-growl.js"></script>
|
||||
<script src="/bower_components/pivottable/examples/pivot.js"></script>
|
||||
<script src="/bower_components/pivottable/dist/pivot.js"></script>
|
||||
<script src="/bower_components/cornelius/src/cornelius.js"></script>
|
||||
<script src="/bower_components/mousetrap/mousetrap.js"></script>
|
||||
<script src="/bower_components/mousetrap/plugins/global-bind/mousetrap-global-bind.js"></script>
|
||||
<script src="/bower_components/select2/select2.js"></script>
|
||||
<script src="/bower_components/angular-ui-select2/src/select2.js"></script>
|
||||
<script src="/bower_components/angular-ui-select/dist/select.js"></script>
|
||||
<script src="/bower_components/underscore.string/lib/underscore.string.js"></script>
|
||||
<script src="/bower_components/marked/lib/marked.js"></script>
|
||||
<script src="/scripts/ng_highchart.js"></script>
|
||||
<script src="/scripts/ng_smart_table.js"></script>
|
||||
<script src="/scripts/ui-bootstrap-tpls-0.5.0.min.js"></script>
|
||||
<script src="/bower_components/bucky/bucky.js"></script>
|
||||
<script src="/bower_components/pace/pace.js"></script>
|
||||
<!-- endbuild -->
|
||||
|
||||
<!-- build:js({.tmp,app}) /scripts/scripts.js -->
|
||||
<script src="/scripts/app.js"></script>
|
||||
<script src="/scripts/controllers.js"></script>
|
||||
<script src="/scripts/admin_controllers.js"></script>
|
||||
<script src="/scripts/directives.js"></script>
|
||||
<script src="/scripts/services.js"></script>
|
||||
<script src="/scripts/filters.js"></script>
|
||||
<script src="/scripts/services/services.js"></script>
|
||||
<script src="/scripts/services/resources.js"></script>
|
||||
<script src="/scripts/services/notifications.js"></script>
|
||||
<script src="/scripts/services/dashboards.js"></script>
|
||||
<script src="/scripts/query_fiddle/renderers.js"></script>
|
||||
<script src="/scripts/controllers/controllers.js"></script>
|
||||
<script src="/scripts/controllers/dashboard.js"></script>
|
||||
<script src="/scripts/controllers/admin_controllers.js"></script>
|
||||
<script src="/scripts/controllers/query_view.js"></script>
|
||||
<script src="/scripts/controllers/query_source.js"></script>
|
||||
<script src="/scripts/visualizations/base.js"></script>
|
||||
<script src="/scripts/visualizations/chart.js"></script>
|
||||
<script src="/scripts/visualizations/cohort.js"></script>
|
||||
<script src="/scripts/visualizations/counter.js"></script>
|
||||
<script src="/scripts/visualizations/table.js"></script>
|
||||
<script src="/scripts/visualizations/pivot.js"></script>
|
||||
<script src="/scripts/directives/directives.js"></script>
|
||||
<script src="/scripts/directives/query_directives.js"></script>
|
||||
<script src="/scripts/directives/dashboard_directives.js"></script>
|
||||
<script src="/scripts/filters.js"></script>
|
||||
<!-- endbuild -->
|
||||
|
||||
<script>
|
||||
var currentUser = {% raw user %};
|
||||
// TODO: move currentUser & features to be an Angular service
|
||||
var featureFlags = {{ features|safe }};
|
||||
var currentUser = {{ user|safe }};
|
||||
|
||||
currentUser.canEdit = function(object) {
|
||||
return object.user && (object.user.indexOf(currentUser.name) != -1);
|
||||
var user_id = object.user_id || (object.user && object.user.id);
|
||||
return user_id && (user_id == currentUser.id);
|
||||
};
|
||||
|
||||
{% raw analytics %}
|
||||
currentUser.hasPermission = function(permission) {
|
||||
return this.permissions.indexOf(permission) != -1;
|
||||
}
|
||||
|
||||
{{ analytics|safe }}
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
85
rd_ui/app/login.html
Normal file
85
rd_ui/app/login.html
Normal file
@@ -0,0 +1,85 @@
|
||||
<!DOCTYPE html>
|
||||
<!--[if lt IE 7]> <html class="no-js lt-ie9 lt-ie8 lt-ie7"> <![endif]-->
|
||||
<!--[if IE 7]> <html class="no-js lt-ie9 lt-ie8"> <![endif]-->
|
||||
<!--[if IE 8]> <html class="no-js lt-ie9"> <![endif]-->
|
||||
<!--[if gt IE 8]><!--> <html class="no-js"> <!--<![endif]-->
|
||||
<head>
|
||||
<title>{{name}} Login</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
|
||||
<!-- build:css /styles/main_login.css -->
|
||||
<link rel="stylesheet" href="/bower_components/bootstrap/dist/css/bootstrap.css">
|
||||
<link rel="stylesheet" href="/styles/redash.css">
|
||||
<link rel="stylesheet" href="/styles/login.css">
|
||||
<!-- endbuild -->
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav class="navbar navbar-inverse navbar-fixed-top" role="navigation">
|
||||
<div class="container">
|
||||
<div class="navbar-header">
|
||||
<button type="button" class="navbar-toggle" data-toggle="collapse"
|
||||
data-target=".navbar-ex1-collapse">
|
||||
<span class="sr-only">Toggle navigation</span>
|
||||
<span class="icon-bar"></span>
|
||||
<span class="icon-bar"></span>
|
||||
<span class="icon-bar"></span>
|
||||
</button>
|
||||
<a class="navbar-brand" href="/"><strong>{{name}}</strong></a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
|
||||
<div class="main">
|
||||
{% if show_google_openid %}
|
||||
|
||||
<div class="row">
|
||||
<a href="/oauth/google?next={{next}}"><img src="/google_login.png" class="login-button"/></a>
|
||||
</div>
|
||||
|
||||
<div class="login-or">
|
||||
<hr class="hr-or">
|
||||
<span class="span-or">or</span>
|
||||
</div>
|
||||
|
||||
{% endif %}
|
||||
|
||||
<form role="form" method="post" name="login">
|
||||
<div class="form-group">
|
||||
<label for="inputUsernameEmail">Username or email</label>
|
||||
<input type="text" class="form-control" id="inputUsernameEmail" name="username" value="{{username}}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<!--<a class="pull-right" href="#">Forgot password?</a>-->
|
||||
<label for="inputPassword">Password</label>
|
||||
<input type="password" class="form-control" id="inputPassword" name="password">
|
||||
</div>
|
||||
<div class="checkbox pull-right">
|
||||
<label>
|
||||
<input type="checkbox" name="remember">
|
||||
Remember me </label>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn btn-primary">
|
||||
Log In
|
||||
</button>
|
||||
</form>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/bower_components/jquery/jquery.js"></script>
|
||||
|
||||
<script>
|
||||
{{ analytics|safe }}
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,23 +0,0 @@
|
||||
(function () {
|
||||
var AdminStatusCtrl = function ($scope, $http, $timeout) {
|
||||
$scope.$parent.pageTitle = "System Status";
|
||||
|
||||
var refresh = function () {
|
||||
$scope.refresh_time = moment().add('minutes', 1);
|
||||
$http.get('/status.json').success(function (data) {
|
||||
$scope.workers = data.workers;
|
||||
delete data.workers;
|
||||
$scope.manager = data.manager;
|
||||
delete data.manager;
|
||||
$scope.status = data;
|
||||
});
|
||||
|
||||
$timeout(refresh, 59 * 1000);
|
||||
};
|
||||
|
||||
refresh();
|
||||
}
|
||||
|
||||
angular.module('redash.admin_controllers', [])
|
||||
.controller('AdminStatusCtrl', ['$scope', '$http', '$timeout', AdminStatusCtrl])
|
||||
})();
|
||||
@@ -5,55 +5,95 @@ angular.module('redash', [
|
||||
'redash.filters',
|
||||
'redash.services',
|
||||
'redash.renderers',
|
||||
'redash.visualization',
|
||||
'ui.codemirror',
|
||||
'highchart',
|
||||
'ui.select2',
|
||||
'angular-growl',
|
||||
'angularMoment',
|
||||
'ui.bootstrap',
|
||||
'smartTable.table',
|
||||
'ngResource',
|
||||
'ngRoute'
|
||||
]).config(['$routeProvider', '$locationProvider', '$compileProvider', 'growlProvider',
|
||||
function($routeProvider, $locationProvider, $compileProvider, growlProvider) {
|
||||
|
||||
$compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|http|data):/);
|
||||
$locationProvider.html5Mode(true);
|
||||
growlProvider.globalTimeToLive(2000);
|
||||
|
||||
$routeProvider.when('/dashboard/:dashboardSlug', {
|
||||
templateUrl: '/views/dashboard.html',
|
||||
controller: 'DashboardCtrl'
|
||||
});
|
||||
$routeProvider.when('/queries', {
|
||||
templateUrl: '/views/queries.html',
|
||||
controller: 'QueriesCtrl',
|
||||
reloadOnSearch: false
|
||||
});
|
||||
$routeProvider.when('/queries/new', {
|
||||
templateUrl: '/views/queryfiddle.html',
|
||||
controller: 'QueryFiddleCtrl',
|
||||
reloadOnSearch: false
|
||||
});
|
||||
$routeProvider.when('/queries/:queryId', {
|
||||
templateUrl: '/views/queryfiddle.html',
|
||||
controller: 'QueryFiddleCtrl',
|
||||
reloadOnSearch: false
|
||||
});
|
||||
$routeProvider.when('/admin/status', {
|
||||
templateUrl: '/views/admin_status.html',
|
||||
controller: 'AdminStatusCtrl'
|
||||
});
|
||||
$routeProvider.when('/', {
|
||||
templateUrl: '/views/index.html',
|
||||
controller: 'IndexCtrl'
|
||||
});
|
||||
$routeProvider.otherwise({
|
||||
redirectTo: '/'
|
||||
'ngRoute',
|
||||
'ui.select'
|
||||
]).config(['$routeProvider', '$locationProvider', '$compileProvider', 'growlProvider',
|
||||
function ($routeProvider, $locationProvider, $compileProvider, growlProvider) {
|
||||
if (featureFlags.clientSideMetrics) {
|
||||
Bucky.setOptions({
|
||||
host: '/api/metrics'
|
||||
});
|
||||
|
||||
Highcharts.setOptions({
|
||||
colors: ["#4572A7", "#AA4643", "#89A54E", "#80699B", "#3D96AE",
|
||||
"#DB843D", "#92A8CD", "#A47D7C", "#B5CA92"]
|
||||
});
|
||||
Bucky.requests.monitor('ajax_requsts');
|
||||
Bucky.requests.transforms.enable('dashboards', /dashboard\/[\w-]+/ig, '/dashboard');
|
||||
}
|
||||
|
||||
function getQuery(Query, $route) {
|
||||
var query = Query.get({'id': $route.current.params.queryId });
|
||||
return query.$promise;
|
||||
};
|
||||
|
||||
$compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|http|data):/);
|
||||
$locationProvider.html5Mode(true);
|
||||
growlProvider.globalTimeToLive(2000);
|
||||
|
||||
$routeProvider.when('/dashboard/:dashboardSlug', {
|
||||
templateUrl: '/views/dashboard.html',
|
||||
controller: 'DashboardCtrl',
|
||||
reloadOnSearch: false
|
||||
});
|
||||
$routeProvider.when('/queries', {
|
||||
templateUrl: '/views/queries.html',
|
||||
controller: 'QueriesCtrl',
|
||||
reloadOnSearch: false
|
||||
});
|
||||
$routeProvider.when('/queries/new', {
|
||||
templateUrl: '/views/query.html',
|
||||
controller: 'QuerySourceCtrl',
|
||||
reloadOnSearch: false,
|
||||
resolve: {
|
||||
'query': ['Query', function newQuery(Query) {
|
||||
return Query.newQuery();
|
||||
}]
|
||||
}
|
||||
});
|
||||
$routeProvider.when('/queries/search', {
|
||||
templateUrl: '/views/queries_search_results.html',
|
||||
controller: 'QuerySearchCtrl',
|
||||
reloadOnSearch: true,
|
||||
});
|
||||
$routeProvider.when('/queries/:queryId', {
|
||||
templateUrl: '/views/query.html',
|
||||
controller: 'QueryViewCtrl',
|
||||
reloadOnSearch: false,
|
||||
resolve: {
|
||||
'query': ['Query', '$route', getQuery]
|
||||
}
|
||||
});
|
||||
$routeProvider.when('/queries/:queryId/source', {
|
||||
templateUrl: '/views/query.html',
|
||||
controller: 'QuerySourceCtrl',
|
||||
reloadOnSearch: false,
|
||||
resolve: {
|
||||
'query': ['Query', '$route', getQuery]
|
||||
}
|
||||
});
|
||||
$routeProvider.when('/admin/status', {
|
||||
templateUrl: '/views/admin_status.html',
|
||||
controller: 'AdminStatusCtrl'
|
||||
});
|
||||
$routeProvider.when('/admin/workers', {
|
||||
templateUrl: '/views/admin_workers.html',
|
||||
controller: 'AdminWorkersCtrl'
|
||||
});
|
||||
|
||||
$routeProvider.when('/', {
|
||||
templateUrl: '/views/index.html',
|
||||
controller: 'IndexCtrl'
|
||||
});
|
||||
$routeProvider.otherwise({
|
||||
redirectTo: '/'
|
||||
});
|
||||
|
||||
|
||||
}
|
||||
]);
|
||||
]);
|
||||
|
||||
@@ -1,395 +0,0 @@
|
||||
(function () {
|
||||
var DashboardCtrl = function ($scope, $routeParams, $http, Dashboard) {
|
||||
$scope.dashboard = Dashboard.get({slug: $routeParams.dashboardSlug}, function(dashboard) {
|
||||
$scope.$parent.pageTitle = dashboard.name;
|
||||
});
|
||||
};
|
||||
|
||||
var WidgetCtrl = function ($scope, $http, $location, Query) {
|
||||
$scope.deleteWidget = function() {
|
||||
if (!confirm('Are you sure you want to remove "' + $scope.widget.visualization.name + '" from the dashboard?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$http.delete('/api/widgets/' + $scope.widget.id).success(function() {
|
||||
$scope.dashboard.widgets = _.map($scope.dashboard.widgets, function(row) {
|
||||
return _.filter(row, function(widget) {
|
||||
return widget.id != $scope.widget.id;
|
||||
})
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
$scope.open = function(query) {
|
||||
$location.path('/queries/' + query.id);
|
||||
}
|
||||
|
||||
$scope.query = new Query($scope.widget.visualization.query);
|
||||
$scope.queryResult = $scope.query.getQueryResult();
|
||||
|
||||
$scope.updateTime = (new Date($scope.queryResult.getUpdatedAt())).toISOString();
|
||||
$scope.nextUpdateTime = moment(new Date(($scope.query.updated_at + $scope.query.ttl + $scope.query.runtime + 300) * 1000)).fromNow();
|
||||
|
||||
$scope.updateTime = '';
|
||||
}
|
||||
|
||||
var QueryFiddleCtrl = function ($scope, $window, $location, $routeParams, $http, $location, growl, notifications, Query, Visualization) {
|
||||
var DEFAULT_TAB = 'table';
|
||||
var pristineHash = null;
|
||||
var leavingPageText = "You will lose your changes if you leave";
|
||||
|
||||
$scope.dirty = undefined;
|
||||
$scope.newVisualization = undefined;
|
||||
|
||||
$window.onbeforeunload = function(){
|
||||
if (currentUser.canEdit($scope.query) && $scope.dirty) {
|
||||
return leavingPageText;
|
||||
}
|
||||
}
|
||||
|
||||
Mousetrap.bindGlobal("meta+s", function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
if (currentUser.canEdit($scope.query)) {
|
||||
$scope.saveQuery();
|
||||
}
|
||||
});
|
||||
|
||||
$scope.$on('$locationChangeStart', function(event, next, current) {
|
||||
if (next.split("#")[0] == current.split("#")[0]) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentUser.canEdit($scope.query)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if($scope.dirty &&
|
||||
!confirm(leavingPageText + "\n\nAre you sure you want to leave this page?")) {
|
||||
event.preventDefault();
|
||||
} else {
|
||||
Mousetrap.unbind("meta+s");
|
||||
}
|
||||
});
|
||||
|
||||
$scope.$parent.pageTitle = "Query Fiddle";
|
||||
|
||||
$scope.$watch(function() {return $location.hash()}, function(hash) {
|
||||
$scope.selectedTab = hash || DEFAULT_TAB;
|
||||
});
|
||||
|
||||
$scope.lockButton = function (lock) {
|
||||
$scope.queryExecuting = lock;
|
||||
};
|
||||
|
||||
$scope.formatQuery = function() {
|
||||
$scope.editorOptions.readOnly = 'nocursor';
|
||||
|
||||
$http.post('/api/queries/format', {'query': $scope.query.query}).success(function(response) {
|
||||
$scope.query.query = response;
|
||||
$scope.editorOptions.readOnly = false;
|
||||
})
|
||||
}
|
||||
|
||||
$scope.saveQuery = function (duplicate, oldId) {
|
||||
if (!oldId) {
|
||||
oldId = $scope.query.id;
|
||||
}
|
||||
delete $scope.query.latest_query_data;
|
||||
$scope.query.$save(function (q) {
|
||||
pristineHash = q.getHash();
|
||||
$scope.dirty = false;
|
||||
|
||||
if (duplicate) {
|
||||
growl.addInfoMessage("Query duplicated.", {ttl: 2000});
|
||||
} else{
|
||||
growl.addSuccessMessage("Query saved.", {ttl: 2000});
|
||||
}
|
||||
|
||||
if (oldId != q.id) {
|
||||
if (oldId == undefined) {
|
||||
$location.path($location.path().replace('new', q.id)).replace();
|
||||
} else {
|
||||
// TODO: replace this with a safer method
|
||||
$location.path($location.path().replace(oldId, q.id)).replace();
|
||||
}
|
||||
}
|
||||
}, function(httpResponse) {
|
||||
growl.addErrorMessage("Query could not be saved");
|
||||
});
|
||||
};
|
||||
|
||||
$scope.duplicateQuery = function () {
|
||||
var oldId = $scope.query.id;
|
||||
$scope.query.id = null;
|
||||
$scope.query.ttl = -1;
|
||||
|
||||
$scope.saveQuery(true, oldId);
|
||||
};
|
||||
|
||||
// Query Editor:
|
||||
$scope.editorOptions = {
|
||||
mode: 'text/x-sql',
|
||||
lineWrapping: true,
|
||||
lineNumbers: true,
|
||||
readOnly: false,
|
||||
matchBrackets: true,
|
||||
autoCloseBrackets: true
|
||||
};
|
||||
|
||||
$scope.refreshOptions = [
|
||||
{value: -1, name: 'No Refresh'},
|
||||
]
|
||||
|
||||
_.each(_.range(1, 13), function(i) {
|
||||
$scope.refreshOptions.push({value: i*3600, name: 'Every ' + i + 'h'});
|
||||
})
|
||||
|
||||
$scope.refreshOptions.push({value: 24*3600, name: 'Every 24h'});
|
||||
$scope.refreshOptions.push({value: 7*24*3600, name: 'Once a week'});
|
||||
|
||||
$scope.$watch('queryResult && queryResult.getError()', function (newError, oldError) {
|
||||
if (newError == undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (oldError == undefined && newError != undefined) {
|
||||
$scope.lockButton(false);
|
||||
}
|
||||
});
|
||||
|
||||
$scope.$watch('queryResult && queryResult.getData()', function (data, oldData) {
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($scope.queryResult.getId() == null) {
|
||||
$scope.dataUri = "";
|
||||
} else {
|
||||
$scope.dataUri = '/api/queries/' + $scope.query.id + '/results/' + $scope.queryResult.getId() + '.csv';
|
||||
$scope.dataFilename = $scope.query.name.replace(" ", "_") + moment($scope.queryResult.getUpdatedAt()).format("_YYYY_MM_DD") + ".csv";
|
||||
}
|
||||
});
|
||||
|
||||
$scope.$watch("queryResult && queryResult.getStatus()", function (status) {
|
||||
if (!status) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (status == "done") {
|
||||
if ($scope.query.id && $scope.query.latest_query_data_id != $scope.queryResult.getId() &&
|
||||
$scope.query.query_hash == $scope.queryResult.query_result.query_hash) {
|
||||
Query.save({'id': $scope.query.id, 'latest_query_data_id': $scope.queryResult.getId()})
|
||||
}
|
||||
$scope.query.latest_query_data_id = $scope.queryResult.getId();
|
||||
|
||||
notifications.showNotification("re:dash", $scope.query.name + " updated.");
|
||||
|
||||
$scope.lockButton(false);
|
||||
}
|
||||
});
|
||||
|
||||
if ($routeParams.queryId != undefined) {
|
||||
$scope.query = Query.get({id: $routeParams.queryId}, function(q) {
|
||||
pristineHash = q.getHash();
|
||||
$scope.dirty = false;
|
||||
$scope.queryResult = $scope.query.getQueryResult();
|
||||
});
|
||||
} else {
|
||||
$scope.query = new Query({query: "", name: "New Query", ttl: -1, user: currentUser.name});
|
||||
$scope.lockButton(false);
|
||||
}
|
||||
|
||||
$scope.$watch('query.name', function() {
|
||||
$scope.$parent.pageTitle = $scope.query.name;
|
||||
});
|
||||
|
||||
$scope.$watch(function() {
|
||||
return $scope.query.getHash();
|
||||
}, function(newHash) {
|
||||
$scope.dirty = (newHash !== pristineHash);
|
||||
});
|
||||
|
||||
$scope.executeQuery = function() {
|
||||
$scope.queryResult = $scope.query.getQueryResult(0);
|
||||
$scope.lockButton(true);
|
||||
$scope.cancelling = false;
|
||||
};
|
||||
|
||||
$scope.cancelExecution = function() {
|
||||
$scope.cancelling = true;
|
||||
$scope.queryResult.cancelExecution();
|
||||
};
|
||||
|
||||
$scope.deleteVisualization = function($e, vis) {
|
||||
$e.preventDefault();
|
||||
if (confirm('Are you sure you want to delete ' + vis.name + ' ?')) {
|
||||
Visualization.delete(vis);
|
||||
if ($scope.selectedTab == vis.id) {
|
||||
$scope.selectedTab = DEFAULT_TAB;
|
||||
}
|
||||
$scope.query.visualizations =
|
||||
$scope.query.visualizations.filter(function(v) {
|
||||
return vis.id !== v.id;
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
var QueriesCtrl = function($scope, $http, $location, $filter, Query) {
|
||||
$scope.$parent.pageTitle = "All Queries";
|
||||
$scope.gridConfig = {
|
||||
isPaginationEnabled: true,
|
||||
itemsByPage: 50,
|
||||
maxSize: 8,
|
||||
isGlobalSearchActivated: true
|
||||
}
|
||||
|
||||
$scope.allQueries = [];
|
||||
$scope.queries = [];
|
||||
|
||||
var dateFormatter = function (value) {
|
||||
if (!value) return "-";
|
||||
return value.format("DD/MM/YY HH:mm");
|
||||
}
|
||||
|
||||
var filterQueries = function() {
|
||||
$scope.queries = _.filter($scope.allQueries, function(query) {
|
||||
if (!$scope.selectedTab) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($scope.selectedTab.key == 'my') {
|
||||
return query.user == currentUser.name && query.name != 'New Query';
|
||||
} else if ($scope.selectedTab.key == 'drafts') {
|
||||
return query.user == currentUser.name && query.name == 'New Query';
|
||||
}
|
||||
|
||||
return query.name != 'New Query';
|
||||
});
|
||||
}
|
||||
|
||||
Query.query(function(queries) {
|
||||
$scope.allQueries = _.map(queries, function(query) {
|
||||
query.created_at = moment(query.created_at);
|
||||
query.last_retrieved_at = moment(query.last_retrieved_at);
|
||||
return query;
|
||||
});
|
||||
|
||||
filterQueries();
|
||||
});
|
||||
|
||||
$scope.gridColumns = [
|
||||
{
|
||||
"label": "Name",
|
||||
"map": "name",
|
||||
"cellTemplateUrl": "/views/queries_query_name_cell.html"
|
||||
},
|
||||
{
|
||||
'label': 'Created By',
|
||||
'map': 'user'
|
||||
},
|
||||
{
|
||||
'label': 'Created At',
|
||||
'map': 'created_at',
|
||||
'formatFunction': dateFormatter
|
||||
},
|
||||
{
|
||||
'label': 'Runtime (avg)',
|
||||
'map': 'avg_runtime',
|
||||
'formatFunction': function(value) {
|
||||
return $filter('durationHumanize')(value);
|
||||
}
|
||||
},
|
||||
{
|
||||
'label': 'Runtime (min)',
|
||||
'map': 'min_runtime',
|
||||
'formatFunction': function(value) {
|
||||
return $filter('durationHumanize')(value);
|
||||
}
|
||||
},
|
||||
{
|
||||
'label': 'Runtime (max)',
|
||||
'map': 'max_runtime',
|
||||
'formatFunction': function(value) {
|
||||
return $filter('durationHumanize')(value);
|
||||
}
|
||||
},
|
||||
{
|
||||
'label': 'Last Executed At',
|
||||
'map': 'last_retrieved_at',
|
||||
'formatFunction': dateFormatter
|
||||
},
|
||||
{
|
||||
'label': 'Times Executed',
|
||||
'map': 'times_retrieved'
|
||||
},
|
||||
{
|
||||
'label': 'Update Schedule',
|
||||
'map': 'ttl',
|
||||
'formatFunction': function(value) {
|
||||
return $filter('refreshRateHumanize')(value);
|
||||
}
|
||||
}
|
||||
]
|
||||
$scope.tabs = [{"name": "My Queries", "key": "my"}, {"key": "all", "name": "All Queries"}, {"key": "drafts", "name": "Drafts"}];
|
||||
|
||||
$scope.$watch('selectedTab', function(tab) {
|
||||
if (tab) {
|
||||
$scope.$parent.pageTitle = tab.name;
|
||||
}
|
||||
|
||||
filterQueries();
|
||||
});
|
||||
}
|
||||
|
||||
var MainCtrl = function ($scope, Dashboard, notifications) {
|
||||
$scope.dashboards = [];
|
||||
$scope.reloadDashboards = function() {
|
||||
Dashboard.query(function (dashboards) {
|
||||
$scope.dashboards = _.sortBy(dashboards, "name");
|
||||
$scope.allDashboards = _.groupBy($scope.dashboards, function(d) {
|
||||
parts = d.name.split(":");
|
||||
if (parts.length == 1) {
|
||||
return "Other";
|
||||
}
|
||||
return parts[0];
|
||||
});
|
||||
$scope.otherDashboards = $scope.allDashboards['Other'] || [];
|
||||
$scope.groupedDashboards = _.omit($scope.allDashboards, 'Other');
|
||||
});
|
||||
}
|
||||
|
||||
$scope.reloadDashboards();
|
||||
|
||||
$scope.currentUser = currentUser;
|
||||
$scope.newDashboard = {
|
||||
'name': null,
|
||||
'layout': null
|
||||
}
|
||||
|
||||
$(window).click(function () {
|
||||
notifications.getPermissions();
|
||||
});
|
||||
}
|
||||
|
||||
var IndexCtrl = function($scope, Dashboard) {
|
||||
$scope.$parent.pageTitle = "Home";
|
||||
|
||||
$scope.archiveDashboard = function(dashboard) {
|
||||
if (confirm('Are you sure you want to delete "' + dashboard.name + '" dashboard?')) {
|
||||
dashboard.$delete(function() {
|
||||
$scope.$parent.reloadDashboards();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
angular.module('redash.controllers', [])
|
||||
.controller('DashboardCtrl', ['$scope', '$routeParams', '$http', 'Dashboard', DashboardCtrl])
|
||||
.controller('WidgetCtrl', ['$scope', '$http', '$location', 'Query', WidgetCtrl])
|
||||
.controller('QueriesCtrl', ['$scope', '$http', '$location', '$filter', 'Query', QueriesCtrl])
|
||||
.controller('QueryFiddleCtrl', ['$scope', '$window', '$location', '$routeParams', '$http', '$location', 'growl', 'notifications', 'Query', 'Visualization', QueryFiddleCtrl])
|
||||
.controller('IndexCtrl', ['$scope', 'Dashboard', IndexCtrl])
|
||||
.controller('MainCtrl', ['$scope', 'Dashboard', 'notifications', MainCtrl]);
|
||||
})();
|
||||
31
rd_ui/app/scripts/controllers/admin_controllers.js
Normal file
31
rd_ui/app/scripts/controllers/admin_controllers.js
Normal file
@@ -0,0 +1,31 @@
|
||||
(function () {
|
||||
var AdminStatusCtrl = function ($scope, Events, $http, $timeout) {
|
||||
Events.record(currentUser, "view", "page", "admin/status");
|
||||
$scope.$parent.pageTitle = "System Status";
|
||||
|
||||
var refresh = function () {
|
||||
$scope.refresh_time = moment().add('minutes', 1);
|
||||
$http.get('/status.json').success(function (data) {
|
||||
$scope.workers = data.workers;
|
||||
delete data.workers;
|
||||
$scope.manager = data.manager;
|
||||
delete data.manager;
|
||||
$scope.status = data;
|
||||
});
|
||||
|
||||
$timeout(refresh, 59 * 1000);
|
||||
};
|
||||
|
||||
$scope.flowerUrl = featureFlags.flowerUrl;
|
||||
|
||||
refresh();
|
||||
}
|
||||
|
||||
var AdminWorkersCtrl = function ($scope, $sce) {
|
||||
$scope.flowerUrl = $sce.trustAsResourceUrl(featureFlags.flowerUrl);
|
||||
};
|
||||
|
||||
angular.module('redash.admin_controllers', [])
|
||||
.controller('AdminStatusCtrl', ['$scope', 'Events', '$http', '$timeout', AdminStatusCtrl])
|
||||
.controller('AdminWorkersCtrl', ['$scope', '$sce', AdminWorkersCtrl])
|
||||
})();
|
||||
216
rd_ui/app/scripts/controllers/controllers.js
Normal file
216
rd_ui/app/scripts/controllers/controllers.js
Normal file
@@ -0,0 +1,216 @@
|
||||
(function () {
|
||||
var QuerySearchCtrl = function($scope, $location, $filter, Events, Query) {
|
||||
$scope.$parent.pageTitle = "Queries Search";
|
||||
|
||||
$scope.gridConfig = {
|
||||
isPaginationEnabled: true,
|
||||
itemsByPage: 50,
|
||||
maxSize: 8,
|
||||
};
|
||||
|
||||
var dateFormatter = function (value) {
|
||||
if (!value) return "-";
|
||||
return value.format("DD/MM/YY HH:mm");
|
||||
}
|
||||
|
||||
$scope.gridColumns = [
|
||||
{
|
||||
"label": "Name",
|
||||
"map": "name",
|
||||
"cellTemplateUrl": "/views/queries_query_name_cell.html"
|
||||
},
|
||||
{
|
||||
'label': 'Created By',
|
||||
'map': 'user.name'
|
||||
},
|
||||
{
|
||||
'label': 'Created At',
|
||||
'map': 'created_at',
|
||||
'formatFunction': dateFormatter
|
||||
},
|
||||
{
|
||||
'label': 'Update Schedule',
|
||||
'map': 'ttl',
|
||||
'formatFunction': function (value) {
|
||||
return $filter('refreshRateHumanize')(value);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
$scope.queries = [];
|
||||
$scope.$parent.term = $location.search().q;
|
||||
|
||||
Query.search({q: $scope.term }, function(results) {
|
||||
$scope.queries = _.map(results, function(query) {
|
||||
query.created_at = moment(query.created_at);
|
||||
return query;
|
||||
});
|
||||
});
|
||||
|
||||
$scope.search = function() {
|
||||
if (!angular.isString($scope.term) || $scope.term.trim() == "") {
|
||||
$scope.queries = [];
|
||||
return;
|
||||
}
|
||||
|
||||
$location.search({q: $scope.term});
|
||||
};
|
||||
|
||||
Events.record(currentUser, "search", "query", "", {"term": $scope.term});
|
||||
};
|
||||
|
||||
var QueriesCtrl = function ($scope, $http, $location, $filter, Query) {
|
||||
$scope.$parent.pageTitle = "All Queries";
|
||||
$scope.gridConfig = {
|
||||
isPaginationEnabled: true,
|
||||
itemsByPage: 50,
|
||||
maxSize: 8,
|
||||
isGlobalSearchActivated: true};
|
||||
|
||||
$scope.allQueries = [];
|
||||
$scope.queries = [];
|
||||
|
||||
var dateFormatter = function (value) {
|
||||
if (!value) return "-";
|
||||
return value.format("DD/MM/YY HH:mm");
|
||||
}
|
||||
|
||||
var filterQueries = function () {
|
||||
$scope.queries = _.filter($scope.allQueries, function (query) {
|
||||
if (!$scope.selectedTab) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($scope.selectedTab.key == 'my') {
|
||||
return query.user.id == currentUser.id && query.name != 'New Query';
|
||||
} else if ($scope.selectedTab.key == 'drafts') {
|
||||
return query.user.id == currentUser.id && query.name == 'New Query';
|
||||
}
|
||||
|
||||
return query.name != 'New Query';
|
||||
});
|
||||
}
|
||||
|
||||
Query.query(function (queries) {
|
||||
$scope.allQueries = _.map(queries, function (query) {
|
||||
query.created_at = moment(query.created_at);
|
||||
query.retrieved_at = moment(query.retrieved_at);
|
||||
return query;
|
||||
});
|
||||
|
||||
filterQueries();
|
||||
});
|
||||
|
||||
$scope.gridColumns = [
|
||||
{
|
||||
"label": "Name",
|
||||
"map": "name",
|
||||
"cellTemplateUrl": "/views/queries_query_name_cell.html"
|
||||
},
|
||||
{
|
||||
'label': 'Created By',
|
||||
'map': 'user.name'
|
||||
},
|
||||
{
|
||||
'label': 'Created At',
|
||||
'map': 'created_at',
|
||||
'formatFunction': dateFormatter
|
||||
},
|
||||
{
|
||||
'label': 'Runtime',
|
||||
'map': 'runtime',
|
||||
'formatFunction': function (value) {
|
||||
return $filter('durationHumanize')(value);
|
||||
}
|
||||
},
|
||||
{
|
||||
'label': 'Last Executed At',
|
||||
'map': 'retrieved_at',
|
||||
'formatFunction': dateFormatter
|
||||
},
|
||||
{
|
||||
'label': 'Update Schedule',
|
||||
'map': 'ttl',
|
||||
'formatFunction': function (value) {
|
||||
return $filter('refreshRateHumanize')(value);
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
$scope.tabs = [
|
||||
{"name": "My Queries", "key": "my"},
|
||||
{"key": "all", "name": "All Queries"},
|
||||
{"key": "drafts", "name": "Drafts"}
|
||||
];
|
||||
|
||||
$scope.$watch('selectedTab', function (tab) {
|
||||
if (tab) {
|
||||
$scope.$parent.pageTitle = tab.name;
|
||||
}
|
||||
|
||||
filterQueries();
|
||||
});
|
||||
}
|
||||
|
||||
var MainCtrl = function ($scope, $location, Dashboard, notifications) {
|
||||
if (featureFlags.clientSideMetrics) {
|
||||
$scope.$on('$locationChangeSuccess', function(event, newLocation, oldLocation) {
|
||||
// This will be called once per actual page load.
|
||||
Bucky.sendPagePerformance();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
$scope.dashboards = [];
|
||||
$scope.reloadDashboards = function () {
|
||||
Dashboard.query(function (dashboards) {
|
||||
$scope.dashboards = _.sortBy(dashboards, "name");
|
||||
$scope.allDashboards = _.groupBy($scope.dashboards, function (d) {
|
||||
parts = d.name.split(":");
|
||||
if (parts.length == 1) {
|
||||
return "Other";
|
||||
}
|
||||
return parts[0];
|
||||
});
|
||||
$scope.otherDashboards = $scope.allDashboards['Other'] || [];
|
||||
$scope.groupedDashboards = _.omit($scope.allDashboards, 'Other');
|
||||
});
|
||||
};
|
||||
|
||||
$scope.searchQueries = function() {
|
||||
$location.path('/queries/search').search({q: $scope.term});
|
||||
};
|
||||
|
||||
$scope.reloadDashboards();
|
||||
|
||||
$scope.currentUser = currentUser;
|
||||
$scope.newDashboard = {
|
||||
'name': null,
|
||||
'layout': null
|
||||
}
|
||||
|
||||
$(window).click(function () {
|
||||
notifications.getPermissions();
|
||||
});
|
||||
}
|
||||
|
||||
var IndexCtrl = function ($scope, Events, Dashboard) {
|
||||
Events.record(currentUser, "view", "page", "homepage");
|
||||
$scope.$parent.pageTitle = "Home";
|
||||
|
||||
$scope.archiveDashboard = function (dashboard) {
|
||||
if (confirm('Are you sure you want to delete "' + dashboard.name + '" dashboard?')) {
|
||||
Events.record(currentUser, "archive", "dashboard", dashboard.id);
|
||||
dashboard.$delete(function () {
|
||||
$scope.$parent.reloadDashboards();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
angular.module('redash.controllers', [])
|
||||
.controller('QueriesCtrl', ['$scope', '$http', '$location', '$filter', 'Query', QueriesCtrl])
|
||||
.controller('IndexCtrl', ['$scope', 'Events', 'Dashboard', IndexCtrl])
|
||||
.controller('MainCtrl', ['$scope', '$location', 'Dashboard', 'notifications', MainCtrl])
|
||||
.controller('QuerySearchCtrl', ['$scope', '$location', '$filter', 'Events', 'Query', QuerySearchCtrl]);
|
||||
})();
|
||||
144
rd_ui/app/scripts/controllers/dashboard.js
Normal file
144
rd_ui/app/scripts/controllers/dashboard.js
Normal file
@@ -0,0 +1,144 @@
|
||||
(function() {
|
||||
var DashboardCtrl = function($scope, Events, Widget, $routeParams, $location, $http, $timeout, $q, Dashboard) {
|
||||
$scope.refreshEnabled = false;
|
||||
$scope.refreshRate = 60;
|
||||
|
||||
var loadDashboard = _.throttle(function() {
|
||||
$scope.dashboard = Dashboard.get({ slug: $routeParams.dashboardSlug }, function (dashboard) {
|
||||
Events.record(currentUser, "view", "dashboard", dashboard.id);
|
||||
|
||||
$scope.$parent.pageTitle = dashboard.name;
|
||||
|
||||
var promises = [];
|
||||
|
||||
$scope.dashboard.widgets = _.map($scope.dashboard.widgets, function (row) {
|
||||
return _.map(row, function (widget) {
|
||||
var w = new Widget(widget);
|
||||
|
||||
if (w.visualization) {
|
||||
promises.push(w.getQuery().getQueryResultPromise());
|
||||
}
|
||||
|
||||
return w;
|
||||
});
|
||||
});
|
||||
|
||||
$q.all(promises).then(function(queryResults) {
|
||||
var filters = {};
|
||||
_.each(queryResults, function(queryResult) {
|
||||
var queryFilters = queryResult.getFilters();
|
||||
_.each(queryFilters, function (filter) {
|
||||
if (!_.has(filters, filter.name)) {
|
||||
// TODO: first object should be a copy, otherwise one of the chart filters behaves different than the others.
|
||||
filters[filter.name] = filter;
|
||||
filters[filter.name].originFilters = [];
|
||||
if (_.has($location.search(), filter.name)) {
|
||||
filter.current = $location.search()[filter.name];
|
||||
}
|
||||
|
||||
$scope.$watch(function () { return filter.current }, function (value) {
|
||||
_.each(filter.originFilters, function (originFilter) {
|
||||
originFilter.current = value;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: merge values.
|
||||
filters[filter.name].originFilters.push(filter);
|
||||
});
|
||||
});
|
||||
|
||||
$scope.filters = _.values(filters);
|
||||
});
|
||||
|
||||
|
||||
}, function () {
|
||||
// error...
|
||||
// try again. we wrap loadDashboard with throttle so it doesn't happen too often.\
|
||||
// we might want to consider exponential backoff and also move this as a general solution in $http/$resource for
|
||||
// all AJAX calls.
|
||||
loadDashboard();
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
loadDashboard();
|
||||
|
||||
var autoRefresh = function() {
|
||||
if ($scope.refreshEnabled) {
|
||||
$timeout(function() {
|
||||
Dashboard.get({
|
||||
slug: $routeParams.dashboardSlug
|
||||
}, function(dashboard) {
|
||||
var newWidgets = _.groupBy(_.flatten(dashboard.widgets), 'id');
|
||||
|
||||
_.each($scope.dashboard.widgets, function(row) {
|
||||
_.each(row, function(widget, i) {
|
||||
var newWidget = newWidgets[widget.id];
|
||||
if (newWidget && newWidget[0].visualization.query.latest_query_data_id != widget.visualization.query.latest_query_data_id) {
|
||||
row[i] = newWidget[0];
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
autoRefresh();
|
||||
});
|
||||
|
||||
}, $scope.refreshRate);
|
||||
}
|
||||
};
|
||||
|
||||
$scope.triggerRefresh = function() {
|
||||
$scope.refreshEnabled = !$scope.refreshEnabled;
|
||||
|
||||
Events.record(currentUser, "autorefresh", "dashboard", dashboard.id, {'enable': $scope.refreshEnabled});
|
||||
|
||||
if ($scope.refreshEnabled) {
|
||||
var refreshRate = _.min(_.flatten($scope.dashboard.widgets), function(widget) {
|
||||
return widget.visualization.query.ttl;
|
||||
}).visualization.query.ttl;
|
||||
|
||||
$scope.refreshRate = _.max([120, refreshRate * 2]) * 1000;
|
||||
|
||||
autoRefresh();
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
var WidgetCtrl = function($scope, Events, Query) {
|
||||
$scope.deleteWidget = function() {
|
||||
if (!confirm('Are you sure you want to remove "' + $scope.widget.getName() + '" from the dashboard?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Events.record(currentUser, "delete", "widget", $scope.widget.id);
|
||||
|
||||
$scope.widget.$delete(function() {
|
||||
$scope.dashboard.widgets = _.map($scope.dashboard.widgets, function(row) {
|
||||
return _.filter(row, function(widget) {
|
||||
return widget.id != undefined;
|
||||
})
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
Events.record(currentUser, "view", "widget", $scope.widget.id);
|
||||
|
||||
if ($scope.widget.visualization) {
|
||||
Events.record(currentUser, "view", "query", $scope.widget.visualization.query.id);
|
||||
Events.record(currentUser, "view", "visualization", $scope.widget.visualization.id);
|
||||
|
||||
$scope.query = $scope.widget.getQuery();
|
||||
$scope.queryResult = $scope.query.getQueryResult();
|
||||
$scope.nextUpdateTime = moment(new Date(($scope.query.updated_at + $scope.query.ttl + $scope.query.runtime + 300) * 1000)).fromNow();
|
||||
|
||||
$scope.type = 'visualization';
|
||||
} else {
|
||||
$scope.type = 'textbox';
|
||||
}
|
||||
};
|
||||
|
||||
angular.module('redash.controllers')
|
||||
.controller('DashboardCtrl', ['$scope', 'Events', 'Widget', '$routeParams', '$location', '$http', '$timeout', '$q', 'Dashboard', DashboardCtrl])
|
||||
.controller('WidgetCtrl', ['$scope', 'Events', 'Query', WidgetCtrl])
|
||||
|
||||
})();
|
||||
120
rd_ui/app/scripts/controllers/query_source.js
Normal file
120
rd_ui/app/scripts/controllers/query_source.js
Normal file
@@ -0,0 +1,120 @@
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
function QuerySourceCtrl(Events, growl, $controller, $scope, $location, Query, Visualization, KeyboardShortcuts) {
|
||||
// extends QueryViewCtrl
|
||||
$controller('QueryViewCtrl', {$scope: $scope});
|
||||
// TODO:
|
||||
// This doesn't get inherited. Setting it on this didn't work either (which is weird).
|
||||
// Obviously it shouldn't be repeated, but we got bigger fish to fry.
|
||||
var DEFAULT_TAB = 'table';
|
||||
|
||||
Events.record(currentUser, 'view_source', 'query', $scope.query.id);
|
||||
|
||||
var isNewQuery = !$scope.query.id,
|
||||
queryText = $scope.query.query,
|
||||
// ref to QueryViewCtrl.saveQuery
|
||||
saveQuery = $scope.saveQuery,
|
||||
shortcuts = {
|
||||
'meta+s': function () {
|
||||
if ($scope.canEdit) {
|
||||
$scope.saveQuery();
|
||||
}
|
||||
},
|
||||
'meta+enter': function () {
|
||||
$scope.executeQuery();
|
||||
}
|
||||
};
|
||||
|
||||
$scope.sourceMode = true;
|
||||
$scope.canEdit = currentUser.canEdit($scope.query);
|
||||
$scope.isDirty = false;
|
||||
|
||||
$scope.newVisualization = undefined;
|
||||
|
||||
// @override
|
||||
Object.defineProperty($scope, 'showDataset', {
|
||||
get: function() {
|
||||
return $scope.queryResult && $scope.queryResult.getStatus() == 'done';
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
KeyboardShortcuts.bind(shortcuts);
|
||||
|
||||
// @override
|
||||
$scope.saveQuery = function(options, data) {
|
||||
var savePromise = saveQuery(options, data);
|
||||
|
||||
savePromise.then(function(savedQuery) {
|
||||
queryText = savedQuery.query;
|
||||
$scope.isDirty = $scope.query.query !== queryText;
|
||||
|
||||
if (isNewQuery) {
|
||||
// redirect to new created query (keep hash)
|
||||
$location.path(savedQuery.getSourceLink()).replace();
|
||||
}
|
||||
});
|
||||
|
||||
return savePromise;
|
||||
};
|
||||
|
||||
$scope.duplicateQuery = function() {
|
||||
Events.record(currentUser, 'fork', 'query', $scope.query.id);
|
||||
$scope.query.id = null;
|
||||
$scope.query.ttl = -1;
|
||||
|
||||
$scope.saveQuery({
|
||||
successMessage: 'Query forked',
|
||||
errorMessage: 'Query could not be forked'
|
||||
}).then(function redirect(savedQuery) {
|
||||
// redirect to forked query (clear hash)
|
||||
$location.url(savedQuery.getSourceLink()).replace()
|
||||
});
|
||||
};
|
||||
|
||||
$scope.deleteVisualization = function($e, vis) {
|
||||
$e.preventDefault();
|
||||
if (confirm('Are you sure you want to delete ' + vis.name + ' ?')) {
|
||||
Events.record(currentUser, 'delete', 'visualization', vis.id);
|
||||
|
||||
Visualization.delete(vis, function() {
|
||||
if ($scope.selectedTab == vis.id) {
|
||||
$scope.selectedTab = DEFAULT_TAB;
|
||||
$location.hash($scope.selectedTab);
|
||||
}
|
||||
$scope.query.visualizations =
|
||||
$scope.query.visualizations.filter(function (v) {
|
||||
return vis.id !== v.id;
|
||||
});
|
||||
}, function () {
|
||||
growl.addErrorMessage("Error deleting visualization. Maybe it's used in a dashboard?");
|
||||
});
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
$scope.$watch('query.query', function(newQueryText) {
|
||||
$scope.isDirty = (newQueryText !== queryText);
|
||||
});
|
||||
|
||||
$scope.$on('$destroy', function destroy() {
|
||||
KeyboardShortcuts.unbind(shortcuts);
|
||||
});
|
||||
|
||||
if (isNewQuery) {
|
||||
// save new query when creating a visualization
|
||||
var unbind = $scope.$watch('selectedTab == "add"', function(triggerSave) {
|
||||
if (triggerSave) {
|
||||
unbind();
|
||||
$scope.saveQuery();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
angular.module('redash.controllers').controller('QuerySourceCtrl', [
|
||||
'Events', 'growl', '$controller', '$scope', '$location', 'Query',
|
||||
'Visualization', 'KeyboardShortcuts', QuerySourceCtrl
|
||||
]);
|
||||
})();
|
||||
144
rd_ui/app/scripts/controllers/query_view.js
Normal file
144
rd_ui/app/scripts/controllers/query_view.js
Normal file
@@ -0,0 +1,144 @@
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
function QueryViewCtrl($scope, Events, $route, $location, notifications, growl, Query, DataSource) {
|
||||
var DEFAULT_TAB = 'table';
|
||||
|
||||
$scope.query = $route.current.locals.query;
|
||||
Events.record(currentUser, 'view', 'query', $scope.query.id);
|
||||
$scope.queryResult = $scope.query.getQueryResult();
|
||||
$scope.queryExecuting = false;
|
||||
|
||||
$scope.isQueryOwner = currentUser.id === $scope.query.user.id;
|
||||
$scope.canViewSource = currentUser.hasPermission('view_source');
|
||||
|
||||
$scope.dataSources = DataSource.get(function(dataSources) {
|
||||
$scope.query.data_source_id = $scope.query.data_source_id || dataSources[0].id;
|
||||
});
|
||||
|
||||
// in view mode, latest dataset is always visible
|
||||
// source mode changes this behavior
|
||||
$scope.showDataset = true;
|
||||
|
||||
$scope.lockButton = function(lock) {
|
||||
$scope.queryExecuting = lock;
|
||||
};
|
||||
|
||||
$scope.saveQuery = function(options, data) {
|
||||
if (data) {
|
||||
data.id = $scope.query.id;
|
||||
} else {
|
||||
data = _.clone($scope.query);
|
||||
}
|
||||
|
||||
options = _.extend({}, {
|
||||
successMessage: 'Query saved',
|
||||
errorMessage: 'Query could not be saved'
|
||||
}, options);
|
||||
|
||||
delete data.latest_query_data;
|
||||
delete data.queryResult;
|
||||
|
||||
return Query.save(data, function() {
|
||||
growl.addSuccessMessage(options.successMessage);
|
||||
}, function(httpResponse) {
|
||||
growl.addErrorMessage(options.errorMessage);
|
||||
}).$promise;
|
||||
}
|
||||
|
||||
$scope.saveDescription = function() {
|
||||
Events.record(currentUser, 'edit_description', 'query', $scope.query.id);
|
||||
$scope.saveQuery(undefined, {'description': $scope.query.description});
|
||||
};
|
||||
|
||||
$scope.saveName = function() {
|
||||
Events.record(currentUser, 'edit_name', 'query', $scope.query.id);
|
||||
$scope.saveQuery(undefined, {'name': $scope.query.name});
|
||||
};
|
||||
|
||||
$scope.executeQuery = function() {
|
||||
$scope.queryResult = $scope.query.getQueryResult(0);
|
||||
$scope.lockButton(true);
|
||||
$scope.cancelling = false;
|
||||
Events.record(currentUser, 'execute', 'query', $scope.query.id);
|
||||
};
|
||||
|
||||
$scope.cancelExecution = function() {
|
||||
$scope.cancelling = true;
|
||||
$scope.queryResult.cancelExecution();
|
||||
Events.record(currentUser, 'cancel_execute', 'query', $scope.query.id);
|
||||
};
|
||||
|
||||
$scope.updateDataSource = function() {
|
||||
Events.record(currentUser, 'update_data_source', 'query', $scope.query.id);
|
||||
|
||||
$scope.query.latest_query_data = null;
|
||||
$scope.query.latest_query_data_id = null;
|
||||
|
||||
if ($scope.query.id) {
|
||||
Query.save({
|
||||
'id': $scope.query.id,
|
||||
'data_source_id': $scope.query.data_source_id,
|
||||
'latest_query_data_id': null
|
||||
});
|
||||
}
|
||||
|
||||
$scope.executeQuery();
|
||||
};
|
||||
|
||||
$scope.setVisualizationTab = function (visualization) {
|
||||
$scope.selectedTab = visualization.id;
|
||||
$location.hash(visualization.id);
|
||||
};
|
||||
|
||||
$scope.$watch('query.name', function() {
|
||||
$scope.$parent.pageTitle = $scope.query.name;
|
||||
});
|
||||
|
||||
$scope.$watch('queryResult && queryResult.getData()', function(data, oldData) {
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
$scope.filters = $scope.queryResult.getFilters();
|
||||
});
|
||||
|
||||
$scope.$watch("queryResult && queryResult.getStatus()", function(status) {
|
||||
if (!status) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (status == 'done') {
|
||||
if ($scope.query.id &&
|
||||
$scope.query.latest_query_data_id != $scope.queryResult.getId() &&
|
||||
$scope.query.query_hash == $scope.queryResult.query_result.query_hash) {
|
||||
Query.save({
|
||||
'id': $scope.query.id,
|
||||
'latest_query_data_id': $scope.queryResult.getId()
|
||||
})
|
||||
}
|
||||
$scope.query.latest_query_data_id = $scope.queryResult.getId();
|
||||
$scope.query.queryResult = $scope.queryResult;
|
||||
|
||||
notifications.showNotification("re:dash", $scope.query.name + " updated.");
|
||||
}
|
||||
|
||||
if (status === 'done' || status === 'failed') {
|
||||
$scope.lockButton(false);
|
||||
}
|
||||
});
|
||||
|
||||
$scope.$watch(function() {
|
||||
return $location.hash()
|
||||
}, function(hash) {
|
||||
if (hash == 'pivot') {
|
||||
Events.record(currentUser, 'pivot', 'query', $scope.query && $scope.query.id);
|
||||
}
|
||||
$scope.selectedTab = hash || DEFAULT_TAB;
|
||||
});
|
||||
};
|
||||
|
||||
angular.module('redash.controllers')
|
||||
.controller('QueryViewCtrl',
|
||||
['$scope', 'Events', '$route', '$location', 'notifications', 'growl', 'Query', 'DataSource', QueryViewCtrl]);
|
||||
})();
|
||||
@@ -1,399 +0,0 @@
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
var directives = angular.module('redash.directives', []);
|
||||
|
||||
directives.directive('rdTab', ['$location', function($location) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
scope: {
|
||||
'id': '@',
|
||||
'name': '@'
|
||||
},
|
||||
transclude: true,
|
||||
template: '<li class="rd-tab" ng-class="{active: id==selectedTab}"><a href="#{{id}}">{{name}}<span ng-transclude></span></a></li>',
|
||||
replace: true,
|
||||
link: function(scope) {
|
||||
scope.$watch(function(){return scope.$parent.selectedTab}, function(tab) {
|
||||
scope.selectedTab = tab;
|
||||
});
|
||||
}
|
||||
}
|
||||
}]);
|
||||
|
||||
directives.directive('rdTabs', ['$location', '$rootScope', function($location, $rootScope) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
scope: {
|
||||
tabsCollection: '=',
|
||||
selectedTab: '='
|
||||
},
|
||||
template: '<ul class="nav nav-tabs"><li ng-class="{active: tab==selectedTab}" ng-repeat="tab in tabsCollection"><a href="#{{tab.key}}">{{tab.name}}</a></li></ul>',
|
||||
replace: true,
|
||||
link: function($scope, element, attrs) {
|
||||
$scope.selectTab = function(tabKey) {
|
||||
$scope.selectedTab = _.find($scope.tabsCollection, function(tab) { return tab.key == tabKey; });
|
||||
}
|
||||
|
||||
$scope.$watch(function() { return $location.hash()}, function(hash) {
|
||||
if (hash) {
|
||||
$scope.selectTab($location.hash());
|
||||
} else {
|
||||
$scope.selectTab($scope.tabsCollection[0].key);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}]);
|
||||
|
||||
directives.directive('editVisulatizationForm', ['Visualization', 'growl', function(Visualization, growl) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
templateUrl: '/views/edit_visualization.html',
|
||||
replace: true,
|
||||
scope: {
|
||||
query: '=',
|
||||
vis: '=?'
|
||||
},
|
||||
link: function(scope, element, attrs) {
|
||||
scope.advancedMode = false;
|
||||
scope.visTypes = {
|
||||
'Chart': Visualization.prototype.TYPES.CHART,
|
||||
'Cohort': Visualization.prototype.TYPES.COHORT,
|
||||
'Table': Visualization.prototype.TYPES.GRID
|
||||
};
|
||||
scope.seriesTypes = {
|
||||
'Line': Visualization.prototype.SERIES_TYPES.LINE,
|
||||
'Bar': Visualization.prototype.SERIES_TYPES.BAR,
|
||||
'Area': Visualization.prototype.SERIES_TYPES.AREA
|
||||
};
|
||||
|
||||
if (!scope.vis) {
|
||||
// create new visualization
|
||||
// wait for query to load to populate with defaults
|
||||
var unwatch = scope.$watch('query', function(q) {
|
||||
if (q && q.id) {
|
||||
unwatch();
|
||||
scope.vis = {
|
||||
'query_id': q.id,
|
||||
'type': Visualization.prototype.TYPES.CHART,
|
||||
'name': q.name,
|
||||
'description': q.description,
|
||||
'options': newOptions()
|
||||
};
|
||||
}
|
||||
}, true);
|
||||
}
|
||||
|
||||
function newOptions(chartType) {
|
||||
if (chartType === Visualization.prototype.TYPES.COHORT) {
|
||||
// empty config at the moment
|
||||
return {};
|
||||
}
|
||||
|
||||
// Chart
|
||||
return {
|
||||
'series': {
|
||||
'type': Visualization.prototype.SERIES_TYPES.LINE
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
scope.toggleAdvancedMode = function() {
|
||||
scope.advancedMode = !scope.advancedMode;
|
||||
};
|
||||
|
||||
scope.typeChanged = function() {
|
||||
scope.vis.options = newOptions();
|
||||
};
|
||||
|
||||
scope.submit = function() {
|
||||
Visualization.save(scope.vis, function success(result) {
|
||||
growl.addSuccessMessage("Visualization saved");
|
||||
|
||||
scope.vis = result;
|
||||
|
||||
var visIds = _.pluck(scope.query.visualizations, 'id');
|
||||
var index = visIds.indexOf(result.id);
|
||||
if (index > -1) {
|
||||
scope.query.visualizations[index] = result;
|
||||
} else {
|
||||
scope.query.visualizations.push(result);
|
||||
}
|
||||
}, function error() {
|
||||
growl.addErrorMessage("Visualization could not be saved");
|
||||
});
|
||||
};
|
||||
}
|
||||
}
|
||||
}]);
|
||||
|
||||
directives.directive('editDashboardForm', ['$http', '$location', '$timeout', 'Dashboard', function($http, $location, $timeout, Dashboard) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
scope: {
|
||||
dashboard: '='
|
||||
},
|
||||
templateUrl: '/views/edit_dashboard.html',
|
||||
replace: true,
|
||||
link: function($scope, element, attrs) {
|
||||
var gridster = element.find(".gridster ul").gridster({
|
||||
widget_margins: [5, 5],
|
||||
widget_base_dimensions: [260, 100],
|
||||
min_cols: 2,
|
||||
max_cols: 2,
|
||||
serialize_params: function($w, wgd) {
|
||||
return {
|
||||
col: wgd.col,
|
||||
row: wgd.row,
|
||||
id: $w.data('widget-id')
|
||||
}
|
||||
}
|
||||
}).data('gridster');
|
||||
|
||||
var gsItemTemplate = '<li data-widget-id="{id}" class="widget panel panel-default gs-w">' +
|
||||
'<div class="panel-heading">{name}' +
|
||||
'</div></li>';
|
||||
|
||||
$scope.$watch('dashboard.widgets', function(widgets) {
|
||||
$timeout(function () {
|
||||
gridster.remove_all_widgets();
|
||||
|
||||
if (widgets && widgets.length) {
|
||||
var layout = [];
|
||||
|
||||
_.each(widgets, function(row, rowIndex) {
|
||||
_.each(row, function(widget, colIndex) {
|
||||
layout.push({
|
||||
id: widget.id,
|
||||
col: colIndex+1,
|
||||
row: rowIndex+1,
|
||||
ySize: 1,
|
||||
xSize: widget.width,
|
||||
name: widget.visualization.name
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
_.each(layout, function(item) {
|
||||
var el = gsItemTemplate.replace('{id}', item.id).replace('{name}', item.name);
|
||||
gridster.add_widget(el, item.xSize, item.ySize, item.col, item.row);
|
||||
|
||||
});
|
||||
}
|
||||
});
|
||||
}, true);
|
||||
|
||||
$scope.saveDashboard = function() {
|
||||
$scope.saveInProgress = true;
|
||||
// TODO: we should use the dashboard service here.
|
||||
if ($scope.dashboard.id) {
|
||||
var positions = $(element).find('.gridster ul').data('gridster').serialize();
|
||||
var layout = [];
|
||||
_.each(_.sortBy(positions, function (pos) {
|
||||
return pos.row * 10 + pos.col;
|
||||
}), function (pos) {
|
||||
var row = pos.row - 1;
|
||||
var col = pos.col - 1;
|
||||
layout[row] = layout[row] || [];
|
||||
if (col > 0 && layout[row][col - 1] == undefined) {
|
||||
layout[row][col - 1] = pos.id;
|
||||
} else {
|
||||
layout[row][col] = pos.id;
|
||||
}
|
||||
|
||||
});
|
||||
$scope.dashboard.layout = layout;
|
||||
|
||||
layout = JSON.stringify(layout);
|
||||
$http.post('/api/dashboards/' + $scope.dashboard.id, {'name': $scope.dashboard.name, 'layout': layout}).success(function(response) {
|
||||
$scope.dashboard = new Dashboard(response);
|
||||
$scope.saveInProgress = false;
|
||||
$(element).modal('hide');
|
||||
})
|
||||
} else {
|
||||
$http.post('/api/dashboards', {'name': $scope.dashboard.name}).success(function(response) {
|
||||
$(element).modal('hide');
|
||||
$location.path('/dashboard/' + response.slug).replace();
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}]);
|
||||
|
||||
directives.directive('newWidgetForm', ['$http', 'Query', function($http, Query) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
scope: {
|
||||
dashboard: '='
|
||||
},
|
||||
templateUrl: '/views/new_widget_form.html',
|
||||
replace: true,
|
||||
link: function($scope, element, attrs) {
|
||||
$scope.widgetSizes = [{name: 'Regular', value: 1}, {name: 'Double', value: 2}];
|
||||
|
||||
var reset = function() {
|
||||
$scope.saveInProgress = false;
|
||||
$scope.widgetSize = 1;
|
||||
$scope.queryId = null;
|
||||
$scope.selectedVis = null;
|
||||
|
||||
}
|
||||
|
||||
reset();
|
||||
|
||||
$scope.toggleView = function(viewName) {
|
||||
$scope.currentView = ($scope.currentView == viewName) ? '' : viewName;
|
||||
};
|
||||
|
||||
$scope.loadVisualizations = function() {
|
||||
if (!$scope.queryId) {
|
||||
return;
|
||||
}
|
||||
|
||||
Query.get({
|
||||
id: $scope.queryId
|
||||
}, function(query) {
|
||||
if (query) {
|
||||
$scope.query = query;
|
||||
if(query.visualizations.length) {
|
||||
$scope.selectedVis = query.visualizations[0];
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
$scope.saveWidget = function() {
|
||||
$scope.saveInProgress = true;
|
||||
|
||||
var widget = {
|
||||
'visualization_id': $scope.selectedVis.id,
|
||||
'dashboard_id': $scope.dashboard.id,
|
||||
'options': {},
|
||||
'width': $scope.widgetSize
|
||||
}
|
||||
|
||||
$http.post('/api/widgets', widget).success(function(response) {
|
||||
// update dashboard layout
|
||||
$scope.dashboard.layout = response['layout'];
|
||||
if (response['new_row']) {
|
||||
$scope.dashboard.widgets.push([response['widget']]);
|
||||
} else {
|
||||
$scope.dashboard.widgets[$scope.dashboard.widgets.length-1].push(response['widget']);
|
||||
}
|
||||
|
||||
// close the dialog
|
||||
$('#add_query_dialog').modal('hide');
|
||||
reset();
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}])
|
||||
|
||||
// From: http://jsfiddle.net/joshdmiller/NDFHg/
|
||||
directives.directive('editInPlace', function () {
|
||||
return {
|
||||
restrict: 'E',
|
||||
scope: {
|
||||
value: '=',
|
||||
ignoreBlanks: '=',
|
||||
editable: '='
|
||||
},
|
||||
template: function(tElement, tAttrs) {
|
||||
var elType = tAttrs.editor || 'input';
|
||||
var placeholder = tAttrs.placeholder || 'Click to edit';
|
||||
return '<span ng-click="editable && edit()" ng-bind="value" ng-class="{editable: editable}"></span>' +
|
||||
'<span ng-click="editable && edit()" ng-show="editable && !value" ng-class="{editable: editable}">' + placeholder + '</span>' +
|
||||
'<{elType} ng-model="value" class="form-control" rows="2"></{elType}>'.replace('{elType}', elType);
|
||||
},
|
||||
link: function ($scope, element, attrs) {
|
||||
// Let's get a reference to the input element, as we'll want to reference it.
|
||||
var inputElement = angular.element(element.children()[2]);
|
||||
|
||||
// This directive should have a set class so we can style it.
|
||||
element.addClass('edit-in-place');
|
||||
|
||||
// Initially, we're not editing.
|
||||
$scope.editing = false;
|
||||
|
||||
// ng-click handler to activate edit-in-place
|
||||
$scope.edit = function () {
|
||||
if ($scope.ignoreBlanks) {
|
||||
$scope.oldValue = $scope.value;
|
||||
}
|
||||
|
||||
$scope.editing = true;
|
||||
|
||||
// We control display through a class on the directive itself. See the CSS.
|
||||
element.addClass('active');
|
||||
|
||||
// And we must focus the element.
|
||||
// `angular.element()` provides a chainable array, like jQuery so to access a native DOM function,
|
||||
// we have to reference the first element in the array.
|
||||
inputElement[0].focus();
|
||||
};
|
||||
|
||||
$(inputElement).blur(function() {
|
||||
if ($scope.ignoreBlanks && _.isEmpty($scope.value)) {
|
||||
$scope.value = $scope.oldValue;
|
||||
}
|
||||
$scope.editing = false;
|
||||
element.removeClass('active');
|
||||
})
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// http://stackoverflow.com/a/17904092/1559840
|
||||
directives.directive('jsonText', function() {
|
||||
return {
|
||||
restrict: 'A',
|
||||
require: 'ngModel',
|
||||
link: function(scope, element, attr, ngModel) {
|
||||
function into(input) {
|
||||
return JSON.parse(input);
|
||||
}
|
||||
function out(data) {
|
||||
return JSON.stringify(data, undefined, 2);
|
||||
}
|
||||
ngModel.$parsers.push(into);
|
||||
ngModel.$formatters.push(out);
|
||||
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
directives.directive('rdTimer', ['$timeout', function ($timeout) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
scope: { timestamp: '=' },
|
||||
template: '{{currentTime}}',
|
||||
controller: ['$scope' ,function ($scope) {
|
||||
$scope.currentTime = "00:00:00";
|
||||
var currentTimeout = null;
|
||||
|
||||
var updateTime = function() {
|
||||
$scope.currentTime = moment(moment() - moment($scope.timestamp)).utc().format("HH:mm:ss")
|
||||
currentTimeout = $timeout(updateTime, 1000);
|
||||
}
|
||||
|
||||
var cancelTimer = function() {
|
||||
if (currentTimeout) {
|
||||
$timeout.cancel(currentTimeout);
|
||||
currentTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
updateTime();
|
||||
|
||||
$scope.$on('$destroy', function () {
|
||||
cancelTimer();
|
||||
});
|
||||
}]
|
||||
};
|
||||
}]);
|
||||
})();
|
||||
222
rd_ui/app/scripts/directives/dashboard_directives.js
Normal file
222
rd_ui/app/scripts/directives/dashboard_directives.js
Normal file
@@ -0,0 +1,222 @@
|
||||
(function() {
|
||||
'use strict'
|
||||
|
||||
var directives = angular.module('redash.directives');
|
||||
|
||||
directives.directive('editDashboardForm', ['Events', '$http', '$location', '$timeout', 'Dashboard',
|
||||
function(Events, $http, $location, $timeout, Dashboard) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
scope: {
|
||||
dashboard: '='
|
||||
},
|
||||
templateUrl: '/views/edit_dashboard.html',
|
||||
replace: true,
|
||||
link: function($scope, element, attrs) {
|
||||
var gridster = element.find(".gridster ul").gridster({
|
||||
widget_margins: [5, 5],
|
||||
widget_base_dimensions: [260, 100],
|
||||
min_cols: 2,
|
||||
max_cols: 2,
|
||||
serialize_params: function($w, wgd) {
|
||||
return {
|
||||
col: wgd.col,
|
||||
row: wgd.row,
|
||||
id: $w.data('widget-id')
|
||||
}
|
||||
}
|
||||
}).data('gridster');
|
||||
|
||||
var gsItemTemplate = '<li data-widget-id="{id}" class="widget panel panel-default gs-w">' +
|
||||
'<div class="panel-heading">{name}' +
|
||||
'</div></li>';
|
||||
|
||||
$scope.$watch('dashboard.widgets && dashboard.widgets.length', function(widgets_length) {
|
||||
$timeout(function() {
|
||||
gridster.remove_all_widgets();
|
||||
|
||||
if ($scope.dashboard.widgets && $scope.dashboard.widgets.length) {
|
||||
var layout = [];
|
||||
|
||||
_.each($scope.dashboard.widgets, function(row, rowIndex) {
|
||||
_.each(row, function(widget, colIndex) {
|
||||
layout.push({
|
||||
id: widget.id,
|
||||
col: colIndex + 1,
|
||||
row: rowIndex + 1,
|
||||
ySize: 1,
|
||||
xSize: widget.width,
|
||||
name: widget.getName()//visualization.query.name
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
_.each(layout, function(item) {
|
||||
var el = gsItemTemplate.replace('{id}', item.id).replace('{name}', item.name);
|
||||
gridster.add_widget(el, item.xSize, item.ySize, item.col, item.row);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$scope.saveDashboard = function() {
|
||||
$scope.saveInProgress = true;
|
||||
// TODO: we should use the dashboard service here.
|
||||
if ($scope.dashboard.id) {
|
||||
var positions = $(element).find('.gridster ul').data('gridster').serialize();
|
||||
var layout = [];
|
||||
_.each(_.sortBy(positions, function(pos) {
|
||||
return pos.row * 10 + pos.col;
|
||||
}), function(pos) {
|
||||
var row = pos.row - 1;
|
||||
var col = pos.col - 1;
|
||||
layout[row] = layout[row] || [];
|
||||
if (col > 0 && layout[row][col - 1] == undefined) {
|
||||
layout[row][col - 1] = pos.id;
|
||||
} else {
|
||||
layout[row][col] = pos.id;
|
||||
}
|
||||
|
||||
});
|
||||
$scope.dashboard.layout = layout;
|
||||
|
||||
layout = JSON.stringify(layout);
|
||||
$http.post('/api/dashboards/' + $scope.dashboard.id, {
|
||||
'name': $scope.dashboard.name,
|
||||
'layout': layout
|
||||
}).success(function(response) {
|
||||
$scope.dashboard = new Dashboard(response);
|
||||
$scope.saveInProgress = false;
|
||||
$(element).modal('hide');
|
||||
});
|
||||
Events.record(currentUser, 'edit', 'dashboard', $scope.dashboard.id);
|
||||
} else {
|
||||
|
||||
$http.post('/api/dashboards', {
|
||||
'name': $scope.dashboard.name
|
||||
}).success(function(response) {
|
||||
$(element).modal('hide');
|
||||
$scope.dashboard = {
|
||||
'name': null,
|
||||
'layout': null
|
||||
};
|
||||
$scope.saveInProgress = false;
|
||||
$location.path('/dashboard/' + response.slug).replace();
|
||||
});
|
||||
Events.record(currentUser, 'create', 'dashboard');
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
directives.directive('newWidgetForm', ['Query', 'Widget', 'growl',
|
||||
function(Query, Widget, growl) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
scope: {
|
||||
dashboard: '='
|
||||
},
|
||||
templateUrl: '/views/new_widget_form.html',
|
||||
replace: true,
|
||||
link: function($scope, element, attrs) {
|
||||
$scope.widgetSizes = [{
|
||||
name: 'Regular',
|
||||
value: 1
|
||||
}, {
|
||||
name: 'Double',
|
||||
value: 2
|
||||
}];
|
||||
|
||||
$scope.type = 'visualization';
|
||||
|
||||
$scope.isVisualization = function () {
|
||||
return $scope.type == 'visualization';
|
||||
};
|
||||
|
||||
$scope.isTextBox = function () {
|
||||
return $scope.type == 'textbox';
|
||||
};
|
||||
|
||||
$scope.setType = function (type) {
|
||||
$scope.type = type;
|
||||
};
|
||||
|
||||
var reset = function() {
|
||||
$scope.saveInProgress = false;
|
||||
$scope.widgetSize = 1;
|
||||
$scope.selectedVis = null;
|
||||
$scope.query = {};
|
||||
$scope.selected_query = undefined;
|
||||
$scope.text = "";
|
||||
};
|
||||
|
||||
reset();
|
||||
|
||||
$scope.loadVisualizations = function () {
|
||||
if (!$scope.query.selected) {
|
||||
return;
|
||||
}
|
||||
|
||||
Query.get({ id: $scope.query.selected.id }, function(query) {
|
||||
if (query) {
|
||||
$scope.selected_query = query;
|
||||
if (query.visualizations.length) {
|
||||
$scope.selectedVis = query.visualizations[0];
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
$scope.searchQueries = function (term) {
|
||||
if (!term || term.length < 3) {
|
||||
return;
|
||||
}
|
||||
|
||||
Query.search({q: term}, function(results) {
|
||||
$scope.queries = results;
|
||||
});
|
||||
};
|
||||
|
||||
$scope.$watch('query', function () {
|
||||
$scope.loadVisualizations();
|
||||
}, true);
|
||||
|
||||
$scope.saveWidget = function() {
|
||||
$scope.saveInProgress = true;
|
||||
|
||||
var widget = new Widget({
|
||||
'visualization_id': $scope.selectedVis && $scope.selectedVis.id,
|
||||
'dashboard_id': $scope.dashboard.id,
|
||||
'options': {},
|
||||
'width': $scope.widgetSize,
|
||||
'text': $scope.text
|
||||
});
|
||||
|
||||
widget.$save().then(function(response) {
|
||||
// update dashboard layout
|
||||
$scope.dashboard.layout = response['layout'];
|
||||
var newWidget = new Widget(response['widget']);
|
||||
if (response['new_row']) {
|
||||
$scope.dashboard.widgets.push([newWidget]);
|
||||
} else {
|
||||
$scope.dashboard.widgets[$scope.dashboard.widgets.length - 1].push(newWidget);
|
||||
}
|
||||
|
||||
// close the dialog
|
||||
$('#add_query_dialog').modal('hide');
|
||||
reset();
|
||||
}).catch(function() {
|
||||
growl.addErrorMessage("Widget can not be added");
|
||||
}).finally(function() {
|
||||
$scope.saveInProgress = false;
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
])
|
||||
})();
|
||||
250
rd_ui/app/scripts/directives/directives.js
Normal file
250
rd_ui/app/scripts/directives/directives.js
Normal file
@@ -0,0 +1,250 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var directives = angular.module('redash.directives', []);
|
||||
|
||||
directives.directive('alertUnsavedChanges', ['$window', function ($window) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
replace: true,
|
||||
scope: {
|
||||
'isDirty': '='
|
||||
},
|
||||
link: function ($scope) {
|
||||
var
|
||||
|
||||
unloadMessage = "You will lose your changes if you leave",
|
||||
confirmMessage = unloadMessage + "\n\nAre you sure you want to leave this page?",
|
||||
|
||||
// store original handler (if any)
|
||||
_onbeforeunload = $window.onbeforeunload;
|
||||
|
||||
$window.onbeforeunload = function () {
|
||||
return $scope.isDirty ? unloadMessage : null;
|
||||
}
|
||||
|
||||
$scope.$on('$locationChangeStart', function (event, next, current) {
|
||||
if (next.split("#")[0] == current.split("#")[0]) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($scope.isDirty && !confirm(confirmMessage)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
$scope.$on('$destroy', function () {
|
||||
$window.onbeforeunload = _onbeforeunload;
|
||||
});
|
||||
}
|
||||
}
|
||||
}]);
|
||||
|
||||
directives.directive('rdTab', function () {
|
||||
return {
|
||||
restrict: 'E',
|
||||
scope: {
|
||||
'tabId': '@',
|
||||
'name': '@'
|
||||
},
|
||||
transclude: true,
|
||||
template: '<li class="rd-tab" ng-class="{active: tabId==selectedTab}"><a href="#{{tabId}}">{{name}}<span ng-transclude></span></a></li>',
|
||||
replace: true,
|
||||
link: function (scope) {
|
||||
scope.$watch(function () {
|
||||
return scope.$parent.selectedTab
|
||||
}, function (tab) {
|
||||
scope.selectedTab = tab;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
directives.directive('rdTabs', ['$location', function ($location) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
scope: {
|
||||
tabsCollection: '=',
|
||||
selectedTab: '='
|
||||
},
|
||||
template: '<ul class="nav nav-tabs"><li ng-class="{active: tab==selectedTab}" ng-repeat="tab in tabsCollection"><a href="#{{tab.key}}">{{tab.name}}</a></li></ul>',
|
||||
replace: true,
|
||||
link: function ($scope, element, attrs) {
|
||||
$scope.selectTab = function (tabKey) {
|
||||
$scope.selectedTab = _.find($scope.tabsCollection, function (tab) {
|
||||
return tab.key == tabKey;
|
||||
});
|
||||
}
|
||||
|
||||
$scope.$watch(function () {
|
||||
return $location.hash()
|
||||
}, function (hash) {
|
||||
if (hash) {
|
||||
$scope.selectTab($location.hash());
|
||||
} else {
|
||||
$scope.selectTab($scope.tabsCollection[0].key);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}]);
|
||||
|
||||
// From: http://jsfiddle.net/joshdmiller/NDFHg/
|
||||
directives.directive('editInPlace', function () {
|
||||
return {
|
||||
restrict: 'E',
|
||||
scope: {
|
||||
value: '=',
|
||||
ignoreBlanks: '=',
|
||||
editable: '=',
|
||||
done: '=',
|
||||
},
|
||||
template: function (tElement, tAttrs) {
|
||||
var elType = tAttrs.editor || 'input';
|
||||
var placeholder = tAttrs.placeholder || 'Click to edit';
|
||||
|
||||
var viewMode = '';
|
||||
|
||||
if (tAttrs.markdown == "true") {
|
||||
viewMode = '<span ng-click="editable && edit()" ng-bind-html="value|markdown" ng-class="{editable: editable}"></span>';
|
||||
} else {
|
||||
viewMode = '<span ng-click="editable && edit()" ng-bind="value" ng-class="{editable: editable}"></span>';
|
||||
}
|
||||
|
||||
var placeholderSpan = '<span ng-click="editable && edit()" ng-show="editable && !value" ng-class="{editable: editable}">' + placeholder + '</span>';
|
||||
var editor = '<{elType} ng-model="value" class="rd-form-control"></{elType}>'.replace('{elType}', elType);
|
||||
|
||||
return viewMode + placeholderSpan + editor;
|
||||
},
|
||||
link: function ($scope, element, attrs) {
|
||||
// Let's get a reference to the input element, as we'll want to reference it.
|
||||
var inputElement = angular.element(element.children()[2]);
|
||||
|
||||
// This directive should have a set class so we can style it.
|
||||
element.addClass('edit-in-place');
|
||||
|
||||
// Initially, we're not editing.
|
||||
$scope.editing = false;
|
||||
|
||||
// ng-click handler to activate edit-in-place
|
||||
$scope.edit = function () {
|
||||
$scope.oldValue = $scope.value;
|
||||
|
||||
$scope.editing = true;
|
||||
|
||||
// We control display through a class on the directive itself. See the CSS.
|
||||
element.addClass('active');
|
||||
|
||||
// And we must focus the element.
|
||||
// `angular.element()` provides a chainable array, like jQuery so to access a native DOM function,
|
||||
// we have to reference the first element in the array.
|
||||
inputElement[0].focus();
|
||||
};
|
||||
|
||||
function save() {
|
||||
if ($scope.editing) {
|
||||
if ($scope.ignoreBlanks && _.isEmpty($scope.value)) {
|
||||
$scope.value = $scope.oldValue;
|
||||
}
|
||||
$scope.editing = false;
|
||||
element.removeClass('active');
|
||||
|
||||
if ($scope.value !== $scope.oldValue) {
|
||||
$scope.done && $scope.done();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$(inputElement).keydown(function (e) {
|
||||
// 'return' or 'enter' key pressed
|
||||
// allow 'shift' to break lines
|
||||
if (e.which === 13 && !e.shiftKey) {
|
||||
save();
|
||||
} else if (e.which === 27) {
|
||||
$scope.value = $scope.oldValue;
|
||||
$scope.$apply(function () {
|
||||
$(inputElement[0]).blur();
|
||||
});
|
||||
}
|
||||
}).blur(function () {
|
||||
save();
|
||||
});
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// http://stackoverflow.com/a/17904092/1559840
|
||||
directives.directive('jsonText', function () {
|
||||
return {
|
||||
restrict: 'A',
|
||||
require: 'ngModel',
|
||||
link: function (scope, element, attr, ngModel) {
|
||||
function into(input) {
|
||||
return JSON.parse(input);
|
||||
}
|
||||
|
||||
function out(data) {
|
||||
return JSON.stringify(data, undefined, 2);
|
||||
}
|
||||
|
||||
ngModel.$parsers.push(into);
|
||||
ngModel.$formatters.push(out);
|
||||
|
||||
scope.$watch(attr.ngModel, function (newValue) {
|
||||
element[0].value = out(newValue);
|
||||
}, true);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
directives.directive('rdTimer', [function () {
|
||||
return {
|
||||
restrict: 'E',
|
||||
scope: { timestamp: '=' },
|
||||
template: '{{currentTime}}',
|
||||
controller: ['$scope' , function ($scope) {
|
||||
$scope.currentTime = "00:00:00";
|
||||
|
||||
// We're using setInterval directly instead of $timeout, to avoid using $apply, to
|
||||
// prevent the digest loop being run every second.
|
||||
var currentTimer = setInterval(function () {
|
||||
$scope.currentTime = moment(moment() - moment($scope.timestamp)).utc().format("HH:mm:ss");
|
||||
$scope.$digest();
|
||||
}, 1000);
|
||||
|
||||
$scope.$on('$destroy', function () {
|
||||
if (currentTimer) {
|
||||
clearInterval(currentTimer);
|
||||
currentTimer = null;
|
||||
}
|
||||
});
|
||||
}]
|
||||
};
|
||||
}]);
|
||||
|
||||
directives.directive('rdTimeAgo', function () {
|
||||
return {
|
||||
restrict: 'E',
|
||||
scope: {
|
||||
value: '='
|
||||
},
|
||||
template: '<span>' +
|
||||
'<span ng-show="value" am-time-ago="value"></span>' +
|
||||
'<span ng-hide="value">-</span>' +
|
||||
'</span>'
|
||||
}
|
||||
});
|
||||
|
||||
// Used instead of autofocus attribute, which doesn't work in Angular as there is no real page load.
|
||||
directives.directive('autofocus',
|
||||
['$timeout', function ($timeout) {
|
||||
return {
|
||||
link: function (scope, element) {
|
||||
$timeout(function () {
|
||||
element[0].focus();
|
||||
});
|
||||
}
|
||||
};
|
||||
}]
|
||||
);
|
||||
})();
|
||||
162
rd_ui/app/scripts/directives/query_directives.js
Normal file
162
rd_ui/app/scripts/directives/query_directives.js
Normal file
@@ -0,0 +1,162 @@
|
||||
(function() {
|
||||
'use strict'
|
||||
|
||||
function queryLink() {
|
||||
return {
|
||||
restrict: 'E',
|
||||
scope: {
|
||||
'query': '=',
|
||||
'visualization': '=?'
|
||||
},
|
||||
template: '<a ng-href="{{link}}" class="query-link">{{query.name}}</a>',
|
||||
link: function(scope, element) {
|
||||
scope.link = '/queries/' + scope.query.id;
|
||||
if (scope.visualization) {
|
||||
if (scope.visualization.type === 'TABLE') {
|
||||
// link to hard-coded table tab instead of the (hidden) visualization tab
|
||||
scope.link += '#table';
|
||||
} else {
|
||||
scope.link += '#' + scope.visualization.id;
|
||||
}
|
||||
}
|
||||
// element.find('a').attr('href', link);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function querySourceLink() {
|
||||
return {
|
||||
restrict: 'E',
|
||||
template: '<span ng-show="query.id && canViewSource">\
|
||||
<a ng-show="!sourceMode"\
|
||||
ng-href="{{query.id}}/source#{{selectedTab}}">Show Source\
|
||||
</a>\
|
||||
<a ng-show="sourceMode"\
|
||||
ng-href="/queries/{{query.id}}#{{selectedTab}}">Hide Source\
|
||||
</a>\
|
||||
</span>'
|
||||
}
|
||||
}
|
||||
|
||||
function queryResultCSVLink() {
|
||||
return {
|
||||
restrict: 'A',
|
||||
link: function (scope, element) {
|
||||
scope.$watch('queryResult && queryResult.getData()', function(data) {
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (scope.queryResult.getId() == null) {
|
||||
element.attr('href', '');
|
||||
} else {
|
||||
element.attr('href', '/api/queries/' + scope.query.id + '/results/' + scope.queryResult.getId() + '.csv');
|
||||
element.attr('download', scope.query.name.replace(" ", "_") + moment(scope.queryResult.getUpdatedAt()).format("_YYYY_MM_DD") + ".csv");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function queryEditor() {
|
||||
return {
|
||||
restrict: 'E',
|
||||
scope: {
|
||||
'query': '=',
|
||||
'lock': '='
|
||||
},
|
||||
template: '<textarea\
|
||||
ui-codemirror="editorOptions"\
|
||||
ng-model="query.query">',
|
||||
link: function($scope) {
|
||||
$scope.editorOptions = {
|
||||
mode: 'text/x-sql',
|
||||
lineWrapping: true,
|
||||
lineNumbers: true,
|
||||
readOnly: false,
|
||||
matchBrackets: true,
|
||||
autoCloseBrackets: true
|
||||
};
|
||||
|
||||
$scope.$watch('lock', function(locked) {
|
||||
$scope.editorOptions.readOnly = locked ? 'nocursor' : false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function queryFormatter($http) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
// don't create new scope to avoid ui-codemirror bug
|
||||
// seehttps://github.com/angular-ui/ui-codemirror/pull/37
|
||||
scope: false,
|
||||
template: '<button type="button" class="btn btn-default btn-xs"\
|
||||
ng-click="formatQuery()">\
|
||||
<span class="glyphicon glyphicon-indent-left"></span>\
|
||||
Format SQL\
|
||||
</button>',
|
||||
link: function($scope) {
|
||||
$scope.formatQuery = function formatQuery() {
|
||||
$scope.queryExecuting = true;
|
||||
$http.post('/api/queries/format', {
|
||||
'query': $scope.query.query
|
||||
}).success(function (response) {
|
||||
$scope.query.query = response;
|
||||
}).finally(function () {
|
||||
$scope.queryExecuting = false;
|
||||
});
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function queryRefreshSelect() {
|
||||
return {
|
||||
restrict: 'E',
|
||||
template: '<select\
|
||||
ng-disabled="!isQueryOwner"\
|
||||
ng-model="query.ttl"\
|
||||
ng-change="saveQuery()"\
|
||||
ng-options="c.value as c.name for c in refreshOptions">\
|
||||
</select>',
|
||||
link: function($scope) {
|
||||
$scope.refreshOptions = [
|
||||
{
|
||||
value: -1,
|
||||
name: 'No Refresh'
|
||||
},
|
||||
{
|
||||
value: 60,
|
||||
name: 'Every minute'
|
||||
},
|
||||
]
|
||||
|
||||
_.each(_.range(1, 13), function (i) {
|
||||
$scope.refreshOptions.push({
|
||||
value: i * 3600,
|
||||
name: 'Every ' + i + 'h'
|
||||
});
|
||||
})
|
||||
|
||||
$scope.refreshOptions.push({
|
||||
value: 24 * 3600,
|
||||
name: 'Every 24h'
|
||||
});
|
||||
$scope.refreshOptions.push({
|
||||
value: 7 * 24 * 3600,
|
||||
name: 'Once a week'
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
angular.module('redash.directives')
|
||||
.directive('queryLink', queryLink)
|
||||
.directive('querySourceLink', querySourceLink)
|
||||
.directive('queryResultLink', queryResultCSVLink)
|
||||
.directive('queryEditor', queryEditor)
|
||||
.directive('queryRefreshSelect', queryRefreshSelect)
|
||||
.directive('queryFormatter', ['$http', queryFormatter]);
|
||||
})();
|
||||
@@ -1,50 +1,87 @@
|
||||
var durationHumanize = function (duration) {
|
||||
var humanized = "";
|
||||
if (duration == undefined) {
|
||||
humanized = "-";
|
||||
} else if (duration < 60) {
|
||||
humanized = Math.round(duration) + "s";
|
||||
} else if (duration > 3600*24) {
|
||||
var days = Math.round(parseFloat(duration) / 60.0 / 60.0 / 24.0);
|
||||
humanized = days + "days";
|
||||
} else if (duration >= 3600) {
|
||||
var hours = Math.round(parseFloat(duration) / 60.0 / 60.0);
|
||||
humanized = hours + "h";
|
||||
} else {
|
||||
var minutes = Math.round(parseFloat(duration) / 60.0);
|
||||
humanized = minutes + "m";
|
||||
}
|
||||
return humanized;
|
||||
}
|
||||
var humanized = "";
|
||||
if (duration == undefined) {
|
||||
humanized = "-";
|
||||
} else if (duration < 60) {
|
||||
humanized = Math.round(duration) + "s";
|
||||
} else if (duration > 3600 * 24) {
|
||||
var days = Math.round(parseFloat(duration) / 60.0 / 60.0 / 24.0);
|
||||
humanized = days + "days";
|
||||
} else if (duration >= 3600) {
|
||||
var hours = Math.round(parseFloat(duration) / 60.0 / 60.0);
|
||||
humanized = hours + "h";
|
||||
} else {
|
||||
var minutes = Math.round(parseFloat(duration) / 60.0);
|
||||
humanized = minutes + "m";
|
||||
}
|
||||
return humanized;
|
||||
};
|
||||
|
||||
var urlPattern = /(^|[\s\n]|<br\/?>)((?:https?|ftp):\/\/[\-A-Z0-9+\u0026\u2019@#\/%?=()~_|!:,.;]*[\-A-Z0-9+\u0026@#\/%=~()_|])/gi;
|
||||
|
||||
angular.module('redash.filters', []).
|
||||
filter('durationHumanize', function () {
|
||||
return durationHumanize;
|
||||
})
|
||||
filter('durationHumanize', function () {
|
||||
return durationHumanize;
|
||||
})
|
||||
|
||||
.filter('refreshRateHumanize', function () {
|
||||
return function (ttl) {
|
||||
if (ttl==-1) {
|
||||
return "Never";
|
||||
} else {
|
||||
return "Every " + durationHumanize(ttl);
|
||||
}
|
||||
}
|
||||
})
|
||||
.filter('refreshRateHumanize', function () {
|
||||
return function (ttl) {
|
||||
if (ttl == -1) {
|
||||
return "Never";
|
||||
} else {
|
||||
return "Every " + durationHumanize(ttl);
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
.filter('toHuman', function() {
|
||||
return function(text) {
|
||||
return text.replace(/_/g, ' ').replace(/(?:^|\s)\S/g, function (a) {
|
||||
return a.toUpperCase();
|
||||
});
|
||||
}
|
||||
})
|
||||
.filter('toHuman', function () {
|
||||
return function (text) {
|
||||
return text.replace(/_/g, ' ').replace(/(?:^|\s)\S/g, function (a) {
|
||||
return a.toUpperCase();
|
||||
});
|
||||
}
|
||||
})
|
||||
|
||||
.filter('colWidth', function () {
|
||||
return function (widgetWidth) {
|
||||
if (widgetWidth == 1) {
|
||||
return 6;
|
||||
}
|
||||
return 12;
|
||||
}
|
||||
});
|
||||
.filter('colWidth', function () {
|
||||
return function (widgetWidth) {
|
||||
if (widgetWidth == 1) {
|
||||
return 6;
|
||||
}
|
||||
return 12;
|
||||
}
|
||||
})
|
||||
|
||||
.filter('capitalize', function () {
|
||||
return function (text) {
|
||||
if (text) {
|
||||
return _.str.capitalize(text);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
})
|
||||
|
||||
.filter('linkify', function () {
|
||||
return function (text) {
|
||||
return text.replace(urlPattern, "$1<a href='$2' target='_blank'>$2</a>");
|
||||
};
|
||||
})
|
||||
|
||||
.filter('markdown', ['$sce', function ($sce) {
|
||||
return function (text) {
|
||||
if (!text) {
|
||||
return "";
|
||||
}
|
||||
return $sce.trustAsHtml(marked(text));
|
||||
}
|
||||
}])
|
||||
|
||||
.filter('trustAsHtml', ['$sce', function ($sce) {
|
||||
return function (text) {
|
||||
if (!text) {
|
||||
return "";
|
||||
}
|
||||
return $sce.trustAsHtml(text);
|
||||
}
|
||||
}]);
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
(function(){
|
||||
'use strict';
|
||||
|
||||
var defaultOptions = {
|
||||
title: {
|
||||
"text": null
|
||||
},
|
||||
tooltip: {
|
||||
valueDecimals: 2,
|
||||
formatter: function () {
|
||||
if (moment.isMoment(this.x)) {
|
||||
var s = '<b>' + moment(this.x).format("DD/MM/YY HH:mm") + '</b>',
|
||||
pointsCount = this.points.length;
|
||||
|
||||
$.each(this.points, function (i, point) {
|
||||
s += '<br/><span style="color:'+point.series.color+'">' + point.series.name + '</span>: ' +
|
||||
Highcharts.numberFormat(point.y);
|
||||
|
||||
if (pointsCount > 1 && point.percentage) {
|
||||
s += " (" + Highcharts.numberFormat(point.percentage) + "%)";
|
||||
}
|
||||
});
|
||||
} else {
|
||||
var s = "<b>" + this.points[0].key + "</b>";
|
||||
$.each(this.points, function (i, point) {
|
||||
s+= '<br/><span style="color:'+point.series.color+'">' + point.series.name + '</span>: ' +
|
||||
Highcharts.numberFormat(point.y);
|
||||
});
|
||||
}
|
||||
|
||||
return s;
|
||||
},
|
||||
shared: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'datetime'
|
||||
},
|
||||
yAxis: {
|
||||
title: {
|
||||
text: null
|
||||
}
|
||||
},
|
||||
exporting: {
|
||||
chartOptions: {
|
||||
title: {
|
||||
text: ''
|
||||
}
|
||||
},
|
||||
buttons: {
|
||||
contextButton: {
|
||||
menuItems: [
|
||||
{
|
||||
text: 'Toggle % Stacking',
|
||||
onclick: function () {
|
||||
var newStacking = "normal";
|
||||
if (this.series[0].options.stacking == "normal") {
|
||||
newStacking = "percent";
|
||||
}
|
||||
|
||||
_.each(this.series, function (series) {
|
||||
series.update({stacking: newStacking}, true);
|
||||
});
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
credits: {
|
||||
enabled: false
|
||||
},
|
||||
plotOptions: {
|
||||
"column": {
|
||||
"stacking": "normal",
|
||||
"pointPadding": 0,
|
||||
"borderWidth": 1,
|
||||
"groupPadding": 0,
|
||||
"shadow": false
|
||||
}
|
||||
},
|
||||
series: []
|
||||
};
|
||||
|
||||
angular.module('highchart', [])
|
||||
.directive('chart', ['$timeout', function ($timeout) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
template: '<div></div>',
|
||||
scope: {
|
||||
options: "=options",
|
||||
series: "=series"
|
||||
},
|
||||
transclude: true,
|
||||
replace: true,
|
||||
|
||||
link: function (scope, element, attrs) {
|
||||
var chartsDefaults = {
|
||||
chart: {
|
||||
renderTo: element[0],
|
||||
type: attrs.type || null,
|
||||
height: attrs.height || null,
|
||||
width: attrs.width || null
|
||||
}
|
||||
};
|
||||
|
||||
var chartOptions = $.extend(true, {}, defaultOptions, chartsDefaults);
|
||||
|
||||
// Update when options change
|
||||
scope.$watch('options', function(newOptions) {
|
||||
initChart(newOptions);
|
||||
}, true);
|
||||
|
||||
//Update when charts data changes
|
||||
scope.$watch(function () {
|
||||
return (scope.series && scope.series.length) || 0;
|
||||
}, function (length) {
|
||||
if (!length || length == 0) {
|
||||
scope.chart.showLoading();
|
||||
} else {
|
||||
drawChart();
|
||||
};
|
||||
}, true);
|
||||
|
||||
function initChart(options) {
|
||||
if (scope.chart) {
|
||||
scope.chart.destroy();
|
||||
}
|
||||
|
||||
$.extend(true, chartOptions, options);
|
||||
|
||||
scope.chart = new Highcharts.Chart(chartOptions);
|
||||
drawChart();
|
||||
}
|
||||
|
||||
function drawChart() {
|
||||
while(scope.chart.series.length > 0) {
|
||||
scope.chart.series[0].remove(true);
|
||||
}
|
||||
|
||||
// todo series.type
|
||||
|
||||
if (_.some(scope.series[0].data, function(p) { return angular.isString(p.x) })) {
|
||||
scope.chart.xAxis[0].update({type: 'category'});
|
||||
|
||||
// We need to make sure that for each category, each series has a value.
|
||||
var categories = _.union.apply(this, _.map(scope.series, function(s) { return _.pluck(s.data,'x')}));
|
||||
|
||||
_.each(scope.series, function(s) {
|
||||
// TODO: move this logic to Query#getChartData
|
||||
var yValues = _.groupBy(s.data, 'x');
|
||||
|
||||
var newData = _.sortBy(_.map(categories, function(category) {
|
||||
return {
|
||||
name: category,
|
||||
y: yValues[category] && yValues[category][0].y
|
||||
}
|
||||
}), 'name');
|
||||
|
||||
s.data = newData;
|
||||
});
|
||||
} else {
|
||||
scope.chart.xAxis[0].update({type: 'datetime'});
|
||||
}
|
||||
|
||||
scope.chart.counters.color = 0;
|
||||
|
||||
_.each(scope.series, function(s) {
|
||||
// here we override the series with the visualization config
|
||||
var _s = $.extend(true, {}, s, chartOptions['series']);
|
||||
scope.chart.addSeries(_s);
|
||||
})
|
||||
|
||||
scope.chart.redraw();
|
||||
scope.chart.hideLoading();
|
||||
}
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
}]);
|
||||
})();
|
||||
353
rd_ui/app/scripts/ng_highchart.js
Normal file
353
rd_ui/app/scripts/ng_highchart.js
Normal file
@@ -0,0 +1,353 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var ColorPalette = {
|
||||
'Blue':'#4572A7',
|
||||
'Red':'#AA4643',
|
||||
'Green': '#89A54E',
|
||||
'Purple': '#80699B',
|
||||
'Cyan': '#3D96AE',
|
||||
'Orange': '#DB843D',
|
||||
'Light Blue': '#92A8CD',
|
||||
'Lilac': '#A47D7C',
|
||||
'Light Green': '#B5CA92',
|
||||
};
|
||||
|
||||
Highcharts.setOptions({
|
||||
colors: _.values(ColorPalette)
|
||||
});
|
||||
|
||||
var defaultOptions = {
|
||||
title: {
|
||||
"text": null
|
||||
},
|
||||
xAxis: {
|
||||
type: 'datetime'
|
||||
},
|
||||
yAxis: [
|
||||
{
|
||||
title: {
|
||||
text: null
|
||||
},
|
||||
// showEmpty: true // by default
|
||||
},
|
||||
{
|
||||
title: {
|
||||
text: null
|
||||
},
|
||||
opposite: true,
|
||||
showEmpty: false
|
||||
}
|
||||
],
|
||||
|
||||
|
||||
tooltip: {
|
||||
valueDecimals: 2,
|
||||
formatter: function () {
|
||||
if (!this.points) {
|
||||
this.points = [this.point];
|
||||
}
|
||||
;
|
||||
|
||||
if (moment.isMoment(this.x)) {
|
||||
var s = '<b>' + moment(this.x).format("DD/MM/YY HH:mm") + '</b>',
|
||||
pointsCount = this.points.length;
|
||||
|
||||
$.each(this.points, function (i, point) {
|
||||
s += '<br/><span style="color:' + point.series.color + '">' + point.series.name + '</span>: ' +
|
||||
Highcharts.numberFormat(point.y);
|
||||
|
||||
if (pointsCount > 1 && point.percentage) {
|
||||
s += " (" + Highcharts.numberFormat(point.percentage) + "%)";
|
||||
}
|
||||
});
|
||||
} else {
|
||||
var points = this.points;
|
||||
var name = points[0].key || points[0].name;
|
||||
|
||||
var s = "<b>" + name + "</b>";
|
||||
|
||||
$.each(points, function (i, point) {
|
||||
if (points.length > 1) {
|
||||
s += '<br/><span style="color:' + point.series.color + '">' + point.series.name + '</span>: ' + Highcharts.numberFormat(point.y);
|
||||
} else {
|
||||
s += ": " + Highcharts.numberFormat(point.y);
|
||||
if (point.percentage < 100) {
|
||||
s += ' (' + Highcharts.numberFormat(point.percentage) + '%)';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return s;
|
||||
},
|
||||
shared: true
|
||||
},
|
||||
exporting: {
|
||||
chartOptions: {
|
||||
title: {
|
||||
text: ''
|
||||
}
|
||||
},
|
||||
buttons: {
|
||||
contextButton: {
|
||||
menuItems: [
|
||||
{
|
||||
text: 'Toggle % Stacking',
|
||||
onclick: function () {
|
||||
var newStacking = "normal";
|
||||
if (this.series[0].options.stacking == "normal") {
|
||||
newStacking = "percent";
|
||||
}
|
||||
|
||||
_.each(this.series, function (series) {
|
||||
series.update({stacking: newStacking}, true);
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
text: 'Select All',
|
||||
onclick: function () {
|
||||
_.each(this.series, function (s) {
|
||||
s.setVisible(true, false);
|
||||
});
|
||||
this.redraw();
|
||||
}
|
||||
},
|
||||
{
|
||||
text: 'Unselect All',
|
||||
onclick: function () {
|
||||
_.each(this.series, function (s) {
|
||||
s.setVisible(false, false);
|
||||
});
|
||||
this.redraw();
|
||||
}
|
||||
},
|
||||
{
|
||||
text: 'Show Total',
|
||||
onclick: function () {
|
||||
var data = {};
|
||||
_.each(this.series, function (s) {
|
||||
s.setVisible(false, false);
|
||||
_.each(s.data, function (p) {
|
||||
data[p.x] = data[p.x] || {'x': p.x, 'y': 0};
|
||||
data[p.x].y = data[p.x].y + p.y;
|
||||
});
|
||||
});
|
||||
|
||||
this.addSeries({
|
||||
data: _.values(data),
|
||||
type: 'line',
|
||||
name: 'Total'
|
||||
}, false)
|
||||
|
||||
this.redraw();
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
credits: {
|
||||
enabled: false
|
||||
},
|
||||
plotOptions: {
|
||||
area: {
|
||||
marker: {
|
||||
enabled: false,
|
||||
symbol: 'circle',
|
||||
radius: 2,
|
||||
states: {
|
||||
hover: {
|
||||
enabled: true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
column: {
|
||||
stacking: "normal",
|
||||
pointPadding: 0,
|
||||
borderWidth: 1,
|
||||
groupPadding: 0,
|
||||
shadow: false
|
||||
},
|
||||
line: {
|
||||
marker: {
|
||||
radius: 1
|
||||
},
|
||||
lineWidth: 2,
|
||||
states: {
|
||||
hover: {
|
||||
lineWidth: 2,
|
||||
marker: {
|
||||
radius: 3
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
pie: {
|
||||
allowPointSelect: true,
|
||||
cursor: 'pointer',
|
||||
dataLabels: {
|
||||
enabled: true,
|
||||
color: '#000000',
|
||||
connectorColor: '#000000',
|
||||
format: '<b>{point.name}</b>: {point.y} ({point.percentage:.1f} %)'
|
||||
}
|
||||
},
|
||||
scatter: {
|
||||
marker: {
|
||||
radius: 5,
|
||||
states: {
|
||||
hover: {
|
||||
enabled: true,
|
||||
lineColor: 'rgb(100,100,100)'
|
||||
}
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
headerFormat: '<b>{series.name}</b><br>',
|
||||
pointFormat: '{point.x}, {point.y}'
|
||||
}
|
||||
}
|
||||
},
|
||||
series: []
|
||||
};
|
||||
|
||||
angular.module('highchart', [])
|
||||
.constant('ColorPalette', ColorPalette)
|
||||
.directive('chart', ['$timeout', function ($timeout) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
template: '<div></div>',
|
||||
scope: {
|
||||
options: "=options",
|
||||
series: "=series"
|
||||
},
|
||||
transclude: true,
|
||||
replace: true,
|
||||
|
||||
link: function (scope, element, attrs) {
|
||||
var chartsDefaults = {
|
||||
chart: {
|
||||
renderTo: element[0],
|
||||
type: attrs.type || null,
|
||||
height: attrs.height || null,
|
||||
width: attrs.width || null
|
||||
}
|
||||
};
|
||||
|
||||
var chartOptions = $.extend(true, {}, defaultOptions, chartsDefaults);
|
||||
|
||||
// $timeout makes sure that this function invoked after the DOM ready. When draw/init
|
||||
// invoked after the DOM is ready, we see first an empty HighCharts objects and later
|
||||
// they get filled up. Which gives the feeling that the charts loading faster (otherwise
|
||||
// we stare at an empty screen until the HighCharts object is ready).
|
||||
$timeout(function () {
|
||||
// Update when options change
|
||||
scope.$watch('options', function (newOptions) {
|
||||
initChart(newOptions);
|
||||
}, true);
|
||||
|
||||
//Update when charts data changes
|
||||
scope.$watchCollection('series', function (series) {
|
||||
if (!series || series.length == 0) {
|
||||
scope.chart.showLoading();
|
||||
} else {
|
||||
drawChart();
|
||||
}
|
||||
;
|
||||
});
|
||||
});
|
||||
|
||||
function initChart(options) {
|
||||
if (scope.chart) {
|
||||
scope.chart.destroy();
|
||||
}
|
||||
;
|
||||
|
||||
$.extend(true, chartOptions, options);
|
||||
|
||||
scope.chart = new Highcharts.Chart(chartOptions);
|
||||
drawChart();
|
||||
}
|
||||
|
||||
function drawChart() {
|
||||
while (scope.chart.series.length > 0) {
|
||||
scope.chart.series[0].remove(false);
|
||||
};
|
||||
|
||||
if (!('xAxis' in chartOptions && 'type' in chartOptions['xAxis'])) {
|
||||
if (scope.series.length > 0 && _.some(scope.series[0].data, function (p) {
|
||||
return (angular.isString(p.x) || angular.isDefined(p.name));
|
||||
})) {
|
||||
chartOptions['xAxis'] = chartOptions['xAxis'] || {};
|
||||
chartOptions['xAxis']['type'] = 'category';
|
||||
} else {
|
||||
chartOptions['xAxis'] = chartOptions['xAxis'] || {};
|
||||
chartOptions['xAxis']['type'] = 'datetime';
|
||||
}
|
||||
}
|
||||
|
||||
if (chartOptions['xAxis']['type'] == 'category' || chartOptions['series']['type']=='pie') {
|
||||
if (!angular.isDefined(scope.series[0].data[0].name)) {
|
||||
// We need to make sure that for each category, each series has a value.
|
||||
var categories = _.union.apply(this, _.map(scope.series, function (s) {
|
||||
return _.pluck(s.data, 'x')
|
||||
}));
|
||||
|
||||
_.each(scope.series, function (s) {
|
||||
// TODO: move this logic to Query#getChartData
|
||||
var yValues = _.groupBy(s.data, 'x');
|
||||
|
||||
var newData = _.map(categories, function (category) {
|
||||
return {
|
||||
name: category,
|
||||
y: (yValues[category] && yValues[category][0].y) || 0
|
||||
}
|
||||
});
|
||||
|
||||
if (categories.length == 1) {
|
||||
newData = _.sortBy(newData, 'y').reverse();
|
||||
}
|
||||
;
|
||||
|
||||
s.data = newData;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
scope.chart.counters.color = 0;
|
||||
|
||||
_.each(scope.series, function (s) {
|
||||
// here we override the series with the visualization config
|
||||
s = _.extend(s, chartOptions['series']);
|
||||
|
||||
if (s.type == 'area') {
|
||||
_.each(s.data, function (p) {
|
||||
// This is an insane hack: somewhere deep in HighChart's code,
|
||||
// when you stack areas, it tries to convert the string representation
|
||||
// of point's x into a number. With the default implementation of toString
|
||||
// it fails....
|
||||
|
||||
if (moment.isMoment(p.x)) {
|
||||
p.x.toString = function () {
|
||||
return String(this.toDate().getTime());
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
;
|
||||
|
||||
scope.chart.addSeries(s, false);
|
||||
});
|
||||
|
||||
scope.chart.redraw();
|
||||
scope.chart.hideLoading();
|
||||
}
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
}]);
|
||||
})();
|
||||
@@ -217,7 +217,7 @@
|
||||
element.html('<div editable-cell="" row="dataRow" column="column" type="column.type"></div>');
|
||||
compile(element.contents())(scope);
|
||||
} else {
|
||||
element.text(scope.formatedValue);
|
||||
element.html(scope.formatedValue);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
var renderers = angular.module('redash.renderers', []);
|
||||
|
||||
renderers.directive('visualizationRenderer', function() {
|
||||
return {
|
||||
restrict: 'E',
|
||||
scope: {
|
||||
visualization: '=',
|
||||
queryResult: '='
|
||||
},
|
||||
template: '<div ng-switch on="visualization.type">' +
|
||||
'<chart-renderer ng-switch-when="CHART" options="visualization.options" query-result="queryResult"></chart-renderer>' +
|
||||
'<grid-renderer ng-switch-when="GRID" options="visualization.options" query-result="queryResult"></grid-renderer>' +
|
||||
'<cohort-renderer ng-switch-when="COHORT" options="visualization.options" query-result="queryResult"></cohort-renderer>' +
|
||||
'</div>',
|
||||
replace: false
|
||||
}
|
||||
});
|
||||
|
||||
renderers.directive('chartRenderer', function () {
|
||||
return {
|
||||
restrict: 'E',
|
||||
scope: {
|
||||
queryResult: '=',
|
||||
options: '=?'
|
||||
},
|
||||
template: "<chart options='chartOptions' series='chartSeries' class='graph'></chart>",
|
||||
replace: false,
|
||||
controller: ['$scope', function ($scope) {
|
||||
$scope.chartSeries = [];
|
||||
$scope.chartOptions = {};
|
||||
|
||||
$scope.$watch('options', function(chartOptions) {
|
||||
if (chartOptions) {
|
||||
$scope.chartOptions = chartOptions;
|
||||
}
|
||||
});
|
||||
$scope.$watch('queryResult && queryResult.getData()', function (data) {
|
||||
if (!data || $scope.queryResult.getData() == null) {
|
||||
$scope.chartSeries.splice(0, $scope.chartSeries.length);
|
||||
} else {
|
||||
$scope.chartSeries.splice(0, $scope.chartSeries.length);
|
||||
|
||||
_.each($scope.queryResult.getChartData(), function (s) {
|
||||
$scope.chartSeries.push(_.extend(s, {'stacking': 'normal'}));
|
||||
});
|
||||
}
|
||||
});
|
||||
}]
|
||||
}
|
||||
})
|
||||
|
||||
renderers.directive('gridRenderer', function () {
|
||||
return {
|
||||
restrict: 'E',
|
||||
scope: {
|
||||
queryResult: '=',
|
||||
itemsPerPage: '='
|
||||
},
|
||||
templateUrl: "/views/grid_renderer.html",
|
||||
replace: false,
|
||||
controller: ['$scope', function ($scope) {
|
||||
$scope.gridColumns = [];
|
||||
$scope.gridData = [];
|
||||
$scope.gridConfig = {
|
||||
isPaginationEnabled: true,
|
||||
itemsByPage: $scope.itemsPerPage || 15,
|
||||
maxSize: 8
|
||||
};
|
||||
|
||||
$scope.$watch('queryResult && queryResult.getData()', function (data) {
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($scope.queryResult.getData() == null) {
|
||||
$scope.gridColumns = [];
|
||||
$scope.gridData = [];
|
||||
$scope.filters = [];
|
||||
} else {
|
||||
|
||||
|
||||
$scope.filters = $scope.queryResult.getFilters();
|
||||
|
||||
var gridData = _.map($scope.queryResult.getData(), function (row) {
|
||||
var newRow = {};
|
||||
_.each(row, function (val, key) {
|
||||
newRow[$scope.queryResult.getColumnCleanName(key)] = val;
|
||||
})
|
||||
return newRow;
|
||||
});
|
||||
|
||||
$scope.gridColumns = _.map($scope.queryResult.getColumnCleanNames(), function (col, i) {
|
||||
var columnDefinition = {
|
||||
'label': $scope.queryResult.getColumnFriendlyNames()[i],
|
||||
'map': col
|
||||
};
|
||||
|
||||
if (gridData.length > 0) {
|
||||
var exampleData = gridData[0][col];
|
||||
if (angular.isNumber(exampleData)) {
|
||||
columnDefinition['formatFunction'] = 'number';
|
||||
columnDefinition['formatParameter'] = 2;
|
||||
} else if (moment.isMoment(exampleData)) {
|
||||
columnDefinition['formatFunction'] = function(value) {
|
||||
return value.format("DD/MM/YY HH:mm");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return columnDefinition;
|
||||
});
|
||||
|
||||
$scope.gridData = _.clone(gridData);
|
||||
|
||||
$scope.$watch('filters', function (filters) {
|
||||
$scope.gridData = _.filter(gridData, function (row) {
|
||||
return _.reduce(filters, function (memo, filter) {
|
||||
if (filter.current == 'All') {
|
||||
return memo && true;
|
||||
}
|
||||
|
||||
return (memo && row[$scope.queryResult.getColumnCleanName(filter.name)] == filter.current);
|
||||
}, true);
|
||||
});
|
||||
}, true);
|
||||
}
|
||||
});
|
||||
}]
|
||||
}
|
||||
})
|
||||
|
||||
renderers.directive('pivotTableRenderer', function () {
|
||||
return {
|
||||
restrict: 'E',
|
||||
scope: {
|
||||
queryResult: '='
|
||||
},
|
||||
template: "",
|
||||
replace: false,
|
||||
link: function($scope, element, attrs) {
|
||||
$scope.$watch('queryResult && queryResult.getData()', function (data) {
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($scope.queryResult.getData() == null) {
|
||||
} else {
|
||||
$(element).pivotUI($scope.queryResult.getData(), {
|
||||
renderers: $.pivotUtilities.renderers
|
||||
}, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
renderers.directive('cohortRenderer', function() {
|
||||
return {
|
||||
restrict: 'E',
|
||||
scope: {
|
||||
queryResult: '='
|
||||
},
|
||||
template: "",
|
||||
replace: false,
|
||||
link: function($scope, element, attrs) {
|
||||
$scope.$watch('queryResult && queryResult.getData()', function (data) {
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($scope.queryResult.getData() == null) {
|
||||
|
||||
} else {
|
||||
var sortedData = _.sortBy($scope.queryResult.getData(), "date");
|
||||
var grouped = _.groupBy(sortedData, "date");
|
||||
var data = _.map(grouped, function(values, date) {
|
||||
var row = [values[0].total];
|
||||
_.each(values, function(value) { row.push(value.value); });
|
||||
return row;
|
||||
});
|
||||
|
||||
var initialDate = moment(sortedData[0].date).toDate(),
|
||||
container = angular.element(element)[0];
|
||||
|
||||
Cornelius.draw({
|
||||
initialDate: initialDate,
|
||||
container: container,
|
||||
cohort: data,
|
||||
title: null,
|
||||
timeInterval: 'daily',
|
||||
labels: {
|
||||
time: 'Activation Day',
|
||||
people: 'Users'
|
||||
},
|
||||
formatHeaderLabel: function (i) {
|
||||
return "Day " + (i - 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,310 +0,0 @@
|
||||
(function () {
|
||||
var QueryResult = function($resource, $timeout) {
|
||||
var QueryResultResource = $resource('/api/query_results/:id', {id: '@id'}, {'post': {'method': 'POST'}});
|
||||
var Job = $resource('/api/jobs/:id', {id: '@id'});
|
||||
|
||||
var updateFunction = function (props) {
|
||||
angular.extend(this, props);
|
||||
if ('query_result' in props) {
|
||||
this.status = "done";
|
||||
|
||||
_.each(this.query_result.data.rows, function (row) {
|
||||
_.each(row, function (v, k) {
|
||||
if (_.isString(v) && v.match(/^\d{4}-\d{2}-\d{2}/)) {
|
||||
row[k] = moment(v);
|
||||
}
|
||||
});
|
||||
});
|
||||
} else if (this.job.status == 3) {
|
||||
this.status = "processing";
|
||||
} else {
|
||||
this.status = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function QueryResult(props) {
|
||||
this.job = {};
|
||||
this.query_result = {};
|
||||
this.status = "waiting";
|
||||
|
||||
this.updatedAt = moment();
|
||||
|
||||
if (props) {
|
||||
updateFunction.apply(this, [props]);
|
||||
}
|
||||
}
|
||||
|
||||
var statuses = {
|
||||
1: "waiting",
|
||||
2: "processing",
|
||||
3: "done",
|
||||
4: "failed"
|
||||
}
|
||||
|
||||
QueryResult.prototype.update = updateFunction;
|
||||
|
||||
QueryResult.prototype.getId = function() {
|
||||
var id = null;
|
||||
if ('query_result' in this) {
|
||||
id = this.query_result.id;
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
QueryResult.prototype.cancelExecution = function() {
|
||||
Job.delete({id: this.job.id});
|
||||
}
|
||||
|
||||
QueryResult.prototype.getStatus = function() {
|
||||
return this.status || statuses[this.job.status];
|
||||
}
|
||||
|
||||
QueryResult.prototype.getError = function() {
|
||||
// TODO: move this logic to the server...
|
||||
if (this.job.error == "None") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return this.job.error;
|
||||
}
|
||||
|
||||
QueryResult.prototype.getUpdatedAt = function() {
|
||||
return this.query_result.retrieved_at || this.job.updated_at*1000.0 || this.updatedAt;
|
||||
}
|
||||
|
||||
QueryResult.prototype.getRuntime = function() {
|
||||
return this.query_result.runtime;
|
||||
}
|
||||
|
||||
QueryResult.prototype.getData = function() {
|
||||
if (!this.query_result.data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var data = this.query_result.data.rows;
|
||||
return data;
|
||||
}
|
||||
|
||||
QueryResult.prototype.getChartData = function () {
|
||||
var series = {};
|
||||
|
||||
_.each(this.getData(), function (row) {
|
||||
var point = {};
|
||||
var seriesName = undefined;
|
||||
var xValue = 0;
|
||||
var yValues = {};
|
||||
|
||||
_.each(row, function (value, definition) {
|
||||
var type = definition.split("::")[1];
|
||||
var name = definition.split("::")[0];
|
||||
|
||||
if (type == 'x') {
|
||||
xValue = value;
|
||||
point[type] = value;
|
||||
}
|
||||
|
||||
if (type == 'y') {
|
||||
yValues[name] = value;
|
||||
point[type] = value;
|
||||
}
|
||||
|
||||
if (type == 'series') {
|
||||
seriesName = String(value);
|
||||
}
|
||||
});
|
||||
|
||||
var addPointToSeries = function(seriesName, point) {
|
||||
if (series[seriesName] == undefined) {
|
||||
series[seriesName] = {
|
||||
name: seriesName,
|
||||
type: 'column',
|
||||
data: []
|
||||
}
|
||||
}
|
||||
|
||||
series[seriesName]['data'].push(point);
|
||||
}
|
||||
|
||||
if (seriesName === undefined) {
|
||||
_.each(yValues, function(yValue, seriesName) {
|
||||
addPointToSeries(seriesName, {'x': xValue, 'y': yValue});
|
||||
});
|
||||
} else {
|
||||
addPointToSeries(seriesName, point);
|
||||
}
|
||||
});
|
||||
|
||||
_.each(series, function(series) {
|
||||
series.data = _.sortBy(series.data, 'x');
|
||||
});
|
||||
|
||||
return _.values(series);
|
||||
};
|
||||
|
||||
QueryResult.prototype.getColumns = function () {
|
||||
if (this.columns == undefined) {
|
||||
this.columns = _.map(this.query_result.data.columns, function(v) {
|
||||
return v.name;
|
||||
})
|
||||
}
|
||||
|
||||
return this.columns;
|
||||
}
|
||||
|
||||
QueryResult.prototype.getColumnCleanName = function (column) {
|
||||
var parts = column.split('::');
|
||||
var name = parts[1];
|
||||
if (parts[0] != '') {
|
||||
// TODO: it's probably time to generalize this.
|
||||
// see also getColumnFriendlyName
|
||||
name = parts[0].replace(/%/g, '__pct').replace(/ /g, '_').replace(/\?/g,'');
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
QueryResult.prototype.getColumnFriendlyName = function (column) {
|
||||
return this.getColumnCleanName(column).replace('__pct', '%').replace(/_/g, ' ').replace(/(?:^|\s)\S/g, function (a) {
|
||||
return a.toUpperCase();
|
||||
});
|
||||
}
|
||||
|
||||
QueryResult.prototype.getColumnCleanNames = function () {
|
||||
return _.map(this.getColumns(), function (col) {
|
||||
return this.getColumnCleanName(col);
|
||||
}, this);
|
||||
}
|
||||
|
||||
QueryResult.prototype.getColumnFriendlyNames = function () {
|
||||
return _.map(this.getColumns(), function (col) {
|
||||
return this.getColumnFriendlyName(col);
|
||||
}, this);
|
||||
}
|
||||
|
||||
QueryResult.prototype.getFilters = function () {
|
||||
var filterNames = [];
|
||||
_.each(this.getColumns(), function (col) {
|
||||
if (col.split('::')[1] == 'filter') {
|
||||
filterNames.push(col);
|
||||
}
|
||||
});
|
||||
|
||||
var filterValues = [];
|
||||
_.each(this.getData(), function (row) {
|
||||
_.each(filterNames, function (filter, i) {
|
||||
if (filterValues[i] == undefined) {
|
||||
filterValues[i] = [];
|
||||
}
|
||||
filterValues[i].push(row[filter]);
|
||||
})
|
||||
});
|
||||
|
||||
var filters = _.map(filterNames, function (filter, i) {
|
||||
var f = {
|
||||
name: filter,
|
||||
friendlyName: this.getColumnFriendlyName(filter),
|
||||
values: _.uniq(filterValues[i])
|
||||
};
|
||||
|
||||
f.current = f.values[0];
|
||||
return f;
|
||||
}, this);
|
||||
|
||||
return filters;
|
||||
};
|
||||
|
||||
var refreshStatus = function(queryResult, query, ttl) {
|
||||
Job.get({'id': queryResult.job.id}, function(response) {
|
||||
queryResult.update(response);
|
||||
|
||||
if (queryResult.getStatus() == "processing" && queryResult.job.query_result_id && queryResult.job.query_result_id != "None") {
|
||||
QueryResultResource.get({'id': queryResult.job.query_result_id}, function(response) {
|
||||
queryResult.update(response);
|
||||
});
|
||||
} else if (queryResult.getStatus() != "failed") {
|
||||
$timeout(function () {
|
||||
refreshStatus(queryResult, query, ttl);
|
||||
}, 3000);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
QueryResult.getById = function (id) {
|
||||
var queryResult = new QueryResult();
|
||||
|
||||
QueryResultResource.get({'id': id}, function (response) {
|
||||
queryResult.update(response);
|
||||
});
|
||||
|
||||
return queryResult;
|
||||
}
|
||||
|
||||
QueryResult.get = function (query, ttl) {
|
||||
var queryResult = new QueryResult();
|
||||
|
||||
QueryResultResource.post({'query': query, 'ttl': ttl}, function (response) {
|
||||
queryResult.update(response);
|
||||
|
||||
if ('job' in response) {
|
||||
refreshStatus(queryResult, query, ttl);
|
||||
}
|
||||
});
|
||||
|
||||
return queryResult;
|
||||
}
|
||||
|
||||
return QueryResult;
|
||||
};
|
||||
|
||||
var Query = function ($resource, QueryResult) {
|
||||
var Query = $resource('/api/queries/:id', {id: '@id'});
|
||||
|
||||
Query.prototype.getQueryResult = function(ttl) {
|
||||
if (ttl == undefined) {
|
||||
ttl = this.ttl;
|
||||
}
|
||||
|
||||
|
||||
var queryResult = null;
|
||||
if (this.latest_query_data && ttl != 0) {
|
||||
queryResult = new QueryResult({'query_result': this.latest_query_data});
|
||||
} else if (this.latest_query_data_id && ttl != 0) {
|
||||
queryResult = QueryResult.getById(this.latest_query_data_id);
|
||||
} else {
|
||||
queryResult = QueryResult.get(this.query, ttl);
|
||||
}
|
||||
|
||||
return queryResult;
|
||||
};
|
||||
|
||||
Query.prototype.getHash = function() {
|
||||
return [this.name, this.description, this.query].join('!#');
|
||||
};
|
||||
|
||||
return Query;
|
||||
};
|
||||
|
||||
var Visualization = function($resource) {
|
||||
var Visualization = $resource('/api/visualizations/:id', {id: '@id'});
|
||||
|
||||
Visualization.prototype = {
|
||||
TYPES: {
|
||||
'CHART': 'CHART',
|
||||
'COHORT': 'COHORT',
|
||||
'GRID': 'GRID'
|
||||
},
|
||||
SERIES_TYPES: {
|
||||
'LINE': 'line',
|
||||
'BAR': 'bar',
|
||||
'AREA': 'area'
|
||||
}
|
||||
};
|
||||
|
||||
return Visualization;
|
||||
};
|
||||
|
||||
angular.module('redash.services', [])
|
||||
.factory('QueryResult', ['$resource', '$timeout', QueryResult])
|
||||
.factory('Query', ['$resource', 'QueryResult', Query])
|
||||
.factory('Visualization', ['$resource', Visualization])
|
||||
|
||||
})();
|
||||
@@ -2,7 +2,7 @@
|
||||
var Dashboard = function($resource) {
|
||||
var resource = $resource('/api/dashboards/:slug', {slug: '@slug'});
|
||||
resource.prototype.canEdit = function() {
|
||||
return currentUser.is_admin || currentUser.canEdit(this);
|
||||
return currentUser.hasPermission('admin') || currentUser.canEdit(this);
|
||||
}
|
||||
return resource;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
(function () {
|
||||
var notifications = function () {
|
||||
var notifications = function (Events) {
|
||||
var notificationService = {};
|
||||
var lastNotification = null;
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
notification.onclick = function () {
|
||||
window.focus();
|
||||
this.cancel();
|
||||
Events.record(currentUser, 'click', 'notification');
|
||||
};
|
||||
|
||||
notification.show()
|
||||
@@ -49,5 +50,5 @@
|
||||
}
|
||||
|
||||
angular.module('redash.services')
|
||||
.factory('notifications', notifications);
|
||||
.factory('notifications', ['Events', notifications]);
|
||||
})();
|
||||
|
||||
456
rd_ui/app/scripts/services/resources.js
Normal file
456
rd_ui/app/scripts/services/resources.js
Normal file
@@ -0,0 +1,456 @@
|
||||
(function () {
|
||||
var QueryResult = function ($resource, $timeout, $q) {
|
||||
var QueryResultResource = $resource('/api/query_results/:id', {id: '@id'}, {'post': {'method': 'POST'}});
|
||||
var Job = $resource('/api/jobs/:id', {id: '@id'});
|
||||
|
||||
var updateFunction = function (props) {
|
||||
angular.extend(this, props);
|
||||
if ('query_result' in props) {
|
||||
this.status = "done";
|
||||
this.filters = undefined;
|
||||
this.filterFreeze = undefined;
|
||||
|
||||
var columnTypes = {};
|
||||
|
||||
_.each(this.query_result.data.rows, function (row) {
|
||||
_.each(row, function (v, k) {
|
||||
if (angular.isNumber(v)) {
|
||||
columnTypes[k] = 'float';
|
||||
} else if (_.isString(v) && v.match(/^\d{4}-\d{2}-\d{2}T/)) {
|
||||
row[k] = moment(v);
|
||||
columnTypes[k] = 'datetime';
|
||||
} else if (_.isString(v) && v.match(/^\d{4}-\d{2}-\d{2}/)) {
|
||||
row[k] = moment(v);
|
||||
columnTypes[k] = 'date';
|
||||
} else if (typeof(v) == 'object') {
|
||||
row[k] = JSON.stringify(v);
|
||||
}
|
||||
}, this);
|
||||
}, this);
|
||||
|
||||
_.each(this.query_result.data.columns, function(column) {
|
||||
if (columnTypes[column.name]) {
|
||||
column.type = columnTypes[column.name];
|
||||
}
|
||||
});
|
||||
|
||||
this.deferred.resolve(this);
|
||||
} else if (this.job.status == 3) {
|
||||
this.status = "processing";
|
||||
} else {
|
||||
this.status = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function QueryResult(props) {
|
||||
this.deferred = $q.defer();
|
||||
this.job = {};
|
||||
this.query_result = {};
|
||||
this.status = "waiting";
|
||||
this.filters = undefined;
|
||||
this.filterFreeze = undefined;
|
||||
|
||||
this.updatedAt = moment();
|
||||
|
||||
if (props) {
|
||||
updateFunction.apply(this, [props]);
|
||||
}
|
||||
}
|
||||
|
||||
var statuses = {
|
||||
1: "waiting",
|
||||
2: "processing",
|
||||
3: "done",
|
||||
4: "failed"
|
||||
}
|
||||
|
||||
QueryResult.prototype.update = updateFunction;
|
||||
|
||||
QueryResult.prototype.getId = function () {
|
||||
var id = null;
|
||||
if ('query_result' in this) {
|
||||
id = this.query_result.id;
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
QueryResult.prototype.cancelExecution = function () {
|
||||
Job.delete({id: this.job.id});
|
||||
}
|
||||
|
||||
QueryResult.prototype.getStatus = function () {
|
||||
return this.status || statuses[this.job.status];
|
||||
}
|
||||
|
||||
QueryResult.prototype.getError = function () {
|
||||
// TODO: move this logic to the server...
|
||||
if (this.job.error == "None") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return this.job.error;
|
||||
}
|
||||
|
||||
QueryResult.prototype.getUpdatedAt = function () {
|
||||
return this.query_result.retrieved_at || this.job.updated_at * 1000.0 || this.updatedAt;
|
||||
}
|
||||
|
||||
QueryResult.prototype.getRuntime = function () {
|
||||
return this.query_result.runtime;
|
||||
}
|
||||
|
||||
QueryResult.prototype.getRawData = function () {
|
||||
if (!this.query_result.data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var data = this.query_result.data.rows;
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
QueryResult.prototype.getData = function () {
|
||||
if (!this.query_result.data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var filterValues = function (filters) {
|
||||
if (!filters) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return _.reduce(filters, function (str, filter) {
|
||||
return str + filter.current;
|
||||
}, "")
|
||||
}
|
||||
|
||||
var filters = this.getFilters();
|
||||
var filterFreeze = filterValues(filters);
|
||||
|
||||
if (this.filterFreeze != filterFreeze) {
|
||||
this.filterFreeze = filterFreeze;
|
||||
|
||||
if (filters) {
|
||||
this.filteredData = _.filter(this.query_result.data.rows, function (row) {
|
||||
return _.reduce(filters, function (memo, filter) {
|
||||
if (!_.isArray(filter.current)) {
|
||||
filter.current = [filter.current];
|
||||
};
|
||||
|
||||
return (memo && _.some(filter.current, function(v) {
|
||||
// We compare with either the value or the String representation of the value,
|
||||
// because Select2 casts true/false to "true"/"false".
|
||||
return v == row[filter.name] || String(row[filter.name]) == v
|
||||
}));
|
||||
}, true);
|
||||
});
|
||||
} else {
|
||||
this.filteredData = this.query_result.data.rows;
|
||||
}
|
||||
}
|
||||
|
||||
return this.filteredData;
|
||||
}
|
||||
|
||||
QueryResult.prototype.getChartData = function (mapping) {
|
||||
var series = {};
|
||||
|
||||
_.each(this.getData(), function (row) {
|
||||
var point = {};
|
||||
var seriesName = undefined;
|
||||
var xValue = 0;
|
||||
var yValues = {};
|
||||
|
||||
_.each(row, function (value, definition) {
|
||||
var name = definition.split("::")[0];
|
||||
var type = definition.split("::")[1];
|
||||
if (mapping) {
|
||||
type = mapping[definition];
|
||||
}
|
||||
|
||||
if (type == 'unused') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (type == 'x') {
|
||||
xValue = value;
|
||||
point[type] = value;
|
||||
}
|
||||
if (type == 'y') {
|
||||
if (value == null) {
|
||||
value = 0;
|
||||
}
|
||||
yValues[name] = value;
|
||||
point[type] = value;
|
||||
}
|
||||
|
||||
if (type == 'series') {
|
||||
seriesName = String(value);
|
||||
}
|
||||
|
||||
if (type == 'multi-filter') {
|
||||
seriesName = String(value);
|
||||
}
|
||||
});
|
||||
|
||||
var addPointToSeries = function (seriesName, point) {
|
||||
if (series[seriesName] == undefined) {
|
||||
series[seriesName] = {
|
||||
name: seriesName,
|
||||
type: 'column',
|
||||
data: []
|
||||
}
|
||||
}
|
||||
|
||||
series[seriesName]['data'].push(point);
|
||||
}
|
||||
|
||||
if (seriesName === undefined) {
|
||||
_.each(yValues, function (yValue, seriesName) {
|
||||
addPointToSeries(seriesName, {'x': xValue, 'y': yValue});
|
||||
});
|
||||
} else {
|
||||
addPointToSeries(seriesName, point);
|
||||
}
|
||||
});
|
||||
|
||||
_.each(series, function (series) {
|
||||
series.data = _.sortBy(series.data, 'x');
|
||||
});
|
||||
|
||||
return _.values(series);
|
||||
};
|
||||
|
||||
QueryResult.prototype.getColumns = function () {
|
||||
if (this.columns == undefined && this.query_result.data) {
|
||||
this.columns = this.query_result.data.columns;
|
||||
}
|
||||
|
||||
return this.columns;
|
||||
}
|
||||
|
||||
QueryResult.prototype.getColumnNames = function () {
|
||||
if (this.columnNames == undefined && this.query_result.data) {
|
||||
this.columnNames = _.map(this.query_result.data.columns, function (v) {
|
||||
return v.name;
|
||||
});
|
||||
}
|
||||
|
||||
return this.columnNames;
|
||||
}
|
||||
|
||||
QueryResult.prototype.getColumnNameWithoutType = function (column) {
|
||||
var parts = column.split('::');
|
||||
if (parts[0] == "" && parts.length == 2) {
|
||||
return parts[1];
|
||||
}
|
||||
return parts[0];
|
||||
};
|
||||
|
||||
var charConversionMap = {
|
||||
'__pct': /%/g,
|
||||
'_': / /g,
|
||||
'__qm': /\?/g,
|
||||
'__brkt': /[\(\)\[\]]/g,
|
||||
'__dash': /-/g,
|
||||
'__amp': /&/g,
|
||||
'__sl': /\//g,
|
||||
'__fsl': /\\/g,
|
||||
};
|
||||
|
||||
QueryResult.prototype.getColumnCleanName = function (column) {
|
||||
var name = this.getColumnNameWithoutType(column);
|
||||
|
||||
if (name != '') {
|
||||
_.each(charConversionMap, function(regex, replacement) {
|
||||
name = name.replace(regex, replacement);
|
||||
});
|
||||
}
|
||||
|
||||
return name;
|
||||
}
|
||||
|
||||
QueryResult.prototype.getColumnFriendlyName = function (column) {
|
||||
return this.getColumnNameWithoutType(column).replace(/(?:^|\s)\S/g, function (a) {
|
||||
return a.toUpperCase();
|
||||
});
|
||||
}
|
||||
|
||||
QueryResult.prototype.getColumnCleanNames = function () {
|
||||
return _.map(this.getColumnNames(), function (col) {
|
||||
return this.getColumnCleanName(col);
|
||||
}, this);
|
||||
}
|
||||
|
||||
QueryResult.prototype.getColumnFriendlyNames = function () {
|
||||
return _.map(this.getColumnNames(), function (col) {
|
||||
return this.getColumnFriendlyName(col);
|
||||
}, this);
|
||||
}
|
||||
|
||||
QueryResult.prototype.getFilters = function () {
|
||||
if (!this.filters) {
|
||||
this.prepareFilters();
|
||||
}
|
||||
|
||||
return this.filters;
|
||||
};
|
||||
|
||||
QueryResult.prototype.prepareFilters = function () {
|
||||
var filters = [];
|
||||
var filterTypes = ['filter', 'multi-filter'];
|
||||
_.each(this.getColumnNames(), function (col) {
|
||||
var type = col.split('::')[1]
|
||||
if (_.contains(filterTypes, type)) {
|
||||
// filter found
|
||||
var filter = {
|
||||
name: col,
|
||||
friendlyName: this.getColumnFriendlyName(col),
|
||||
values: [],
|
||||
multiple: (type=='multi-filter')
|
||||
}
|
||||
filters.push(filter);
|
||||
}
|
||||
}, this);
|
||||
|
||||
_.each(this.getRawData(), function (row) {
|
||||
_.each(filters, function (filter) {
|
||||
filter.values.push(row[filter.name]);
|
||||
if (filter.values.length == 1) {
|
||||
filter.current = row[filter.name];
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
_.each(filters, function(filter) {
|
||||
filter.values = _.uniq(filter.values);
|
||||
});
|
||||
|
||||
this.filters = filters;
|
||||
}
|
||||
|
||||
var refreshStatus = function (queryResult, query, ttl) {
|
||||
Job.get({'id': queryResult.job.id}, function (response) {
|
||||
queryResult.update(response);
|
||||
|
||||
if (queryResult.getStatus() == "processing" && queryResult.job.query_result_id && queryResult.job.query_result_id != "None") {
|
||||
QueryResultResource.get({'id': queryResult.job.query_result_id}, function (response) {
|
||||
queryResult.update(response);
|
||||
});
|
||||
} else if (queryResult.getStatus() != "failed") {
|
||||
$timeout(function () {
|
||||
refreshStatus(queryResult, query, ttl);
|
||||
}, 3000);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
QueryResult.getById = function (id) {
|
||||
var queryResult = new QueryResult();
|
||||
|
||||
QueryResultResource.get({'id': id}, function (response) {
|
||||
queryResult.update(response);
|
||||
});
|
||||
|
||||
return queryResult;
|
||||
};
|
||||
|
||||
QueryResult.prototype.toPromise = function() {
|
||||
return this.deferred.promise;
|
||||
}
|
||||
|
||||
QueryResult.get = function (data_source_id, query, ttl) {
|
||||
var queryResult = new QueryResult();
|
||||
|
||||
QueryResultResource.post({'data_source_id': data_source_id, 'query': query, 'ttl': ttl}, function (response) {
|
||||
queryResult.update(response);
|
||||
|
||||
if ('job' in response) {
|
||||
refreshStatus(queryResult, query, ttl);
|
||||
}
|
||||
});
|
||||
|
||||
return queryResult;
|
||||
}
|
||||
|
||||
return QueryResult;
|
||||
};
|
||||
|
||||
var Query = function ($resource, QueryResult, DataSource) {
|
||||
var Query = $resource('/api/queries/:id', {id: '@id'}, {search: {method: 'get', isArray: true, url: "/api/queries/search"}});
|
||||
|
||||
Query.newQuery = function () {
|
||||
return new Query({
|
||||
query: "",
|
||||
name: "New Query",
|
||||
ttl: -1,
|
||||
user: currentUser
|
||||
});
|
||||
};
|
||||
|
||||
Query.prototype.getSourceLink = function () {
|
||||
return '/queries/' + this.id + '/source';
|
||||
};
|
||||
|
||||
Query.prototype.getQueryResult = function (ttl) {
|
||||
if (ttl == undefined) {
|
||||
ttl = this.ttl;
|
||||
}
|
||||
|
||||
if (this.latest_query_data && ttl != 0) {
|
||||
if (!this.queryResult) {
|
||||
this.queryResult = new QueryResult({'query_result': this.latest_query_data});
|
||||
}
|
||||
} else if (this.latest_query_data_id && ttl != 0) {
|
||||
if (!this.queryResult) {
|
||||
this.queryResult = QueryResult.getById(this.latest_query_data_id);
|
||||
}
|
||||
} else if (this.data_source_id) {
|
||||
this.queryResult = QueryResult.get(this.data_source_id, this.query, ttl);
|
||||
}
|
||||
|
||||
return this.queryResult;
|
||||
};
|
||||
|
||||
Query.prototype.getQueryResultPromise = function() {
|
||||
return this.getQueryResult().toPromise();
|
||||
}
|
||||
|
||||
return Query;
|
||||
};
|
||||
|
||||
|
||||
|
||||
var DataSource = function ($resource) {
|
||||
var DataSourceResource = $resource('/api/data_sources/:id', {id: '@id'}, {'get': {'method': 'GET', 'cache': true, 'isArray': true}});
|
||||
|
||||
return DataSourceResource;
|
||||
}
|
||||
|
||||
var Widget = function ($resource, Query) {
|
||||
var WidgetResource = $resource('/api/widgets/:id', {id: '@id'});
|
||||
|
||||
WidgetResource.prototype.getQuery = function () {
|
||||
if (!this.query && this.visualization) {
|
||||
this.query = new Query(this.visualization.query);
|
||||
}
|
||||
|
||||
return this.query;
|
||||
};
|
||||
|
||||
WidgetResource.prototype.getName = function () {
|
||||
if (this.visualization) {
|
||||
return this.visualization.query.name + ' (' + this.visualization.name + ')';
|
||||
}
|
||||
return _.str.truncate(this.text, 20);
|
||||
};
|
||||
|
||||
return WidgetResource;
|
||||
}
|
||||
|
||||
angular.module('redash.services')
|
||||
.factory('QueryResult', ['$resource', '$timeout', '$q', QueryResult])
|
||||
.factory('Query', ['$resource', 'QueryResult', 'DataSource', Query])
|
||||
.factory('DataSource', ['$resource', DataSource])
|
||||
.factory('Widget', ['$resource', 'Query', Widget]);
|
||||
})();
|
||||
52
rd_ui/app/scripts/services/services.js
Normal file
52
rd_ui/app/scripts/services/services.js
Normal file
@@ -0,0 +1,52 @@
|
||||
(function () {
|
||||
'use strict'
|
||||
|
||||
function KeyboardShortcuts() {
|
||||
this.bind = function bind(keymap) {
|
||||
_.forEach(keymap, function (fn, key) {
|
||||
Mousetrap.bindGlobal(key, function (e) {
|
||||
e.preventDefault();
|
||||
fn();
|
||||
});
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
this.unbind = function unbind(keymap) {
|
||||
_.forEach(keymap, function (fn, key) {
|
||||
Mousetrap.unbind(key);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function Events($http) {
|
||||
this.events = [];
|
||||
|
||||
this.post = _.debounce(function() {
|
||||
var events = this.events;
|
||||
this.events = [];
|
||||
|
||||
$http.post('/api/events', events);
|
||||
|
||||
}, 1000);
|
||||
|
||||
this.record = function (user, action, object_type, object_id, additional_properties) {
|
||||
|
||||
var event = {
|
||||
"user_id": user.id,
|
||||
"action": action,
|
||||
"object_type": object_type,
|
||||
"object_id": object_id,
|
||||
"timestamp": Date.now()/1000.0
|
||||
};
|
||||
_.extend(event, additional_properties);
|
||||
this.events.push(event);
|
||||
|
||||
this.post();
|
||||
};
|
||||
}
|
||||
|
||||
angular.module('redash.services', [])
|
||||
.service('KeyboardShortcuts', [KeyboardShortcuts])
|
||||
.service('Events', ['$http', Events])
|
||||
})();
|
||||
213
rd_ui/app/scripts/visualizations/base.js
Normal file
213
rd_ui/app/scripts/visualizations/base.js
Normal file
@@ -0,0 +1,213 @@
|
||||
(function () {
|
||||
var VisualizationProvider = function () {
|
||||
this.visualizations = {};
|
||||
this.visualizationTypes = {};
|
||||
var defaultConfig = {
|
||||
defaultOptions: {},
|
||||
skipTypes: false,
|
||||
editorTemplate: null
|
||||
}
|
||||
|
||||
this.registerVisualization = function (config) {
|
||||
var visualization = _.extend({}, defaultConfig, config);
|
||||
|
||||
// TODO: this is prone to errors; better refactor.
|
||||
if (_.isEmpty(this.visualizations)) {
|
||||
this.defaultVisualization = visualization;
|
||||
}
|
||||
|
||||
this.visualizations[config.type] = visualization;
|
||||
|
||||
if (!config.skipTypes) {
|
||||
this.visualizationTypes[config.name] = config.type;
|
||||
}
|
||||
;
|
||||
};
|
||||
|
||||
this.getSwitchTemplate = function (property) {
|
||||
var pattern = /(<[a-zA-Z0-9-]*?)( |>)/
|
||||
|
||||
var mergedTemplates = _.reduce(this.visualizations, function (templates, visualization) {
|
||||
if (visualization[property]) {
|
||||
var ngSwitch = '$1 ng-switch-when="' + visualization.type + '" $2';
|
||||
var template = visualization[property].replace(pattern, ngSwitch);
|
||||
|
||||
return templates + "\n" + template;
|
||||
}
|
||||
|
||||
return templates;
|
||||
}, "");
|
||||
|
||||
mergedTemplates = '<div ng-switch on="visualization.type">' + mergedTemplates + "</div>";
|
||||
|
||||
return mergedTemplates;
|
||||
}
|
||||
|
||||
this.$get = ['$resource', function ($resource) {
|
||||
var Visualization = $resource('/api/visualizations/:id', {id: '@id'});
|
||||
Visualization.visualizations = this.visualizations;
|
||||
Visualization.visualizationTypes = this.visualizationTypes;
|
||||
Visualization.renderVisualizationsTemplate = this.getSwitchTemplate('renderTemplate');
|
||||
Visualization.editorTemplate = this.getSwitchTemplate('editorTemplate');
|
||||
Visualization.defaultVisualization = this.defaultVisualization;
|
||||
|
||||
return Visualization;
|
||||
}];
|
||||
};
|
||||
|
||||
var VisualizationRenderer = function ($location, Visualization) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
scope: {
|
||||
visualization: '=',
|
||||
queryResult: '='
|
||||
},
|
||||
// TODO: using switch here (and in the options editor) might introduce errors and bad
|
||||
// performance wise. It's better to eventually show the correct template based on the
|
||||
// visualization type and not make the browser render all of them.
|
||||
template: '<filters></filters>\n' + Visualization.renderVisualizationsTemplate,
|
||||
replace: false,
|
||||
link: function (scope) {
|
||||
scope.select2Options = {
|
||||
width: '50%'
|
||||
};
|
||||
|
||||
function readURL() {
|
||||
var searchFilters = angular.fromJson($location.search().filters);
|
||||
if (searchFilters) {
|
||||
_.forEach(scope.filters, function(filter) {
|
||||
var value = searchFilters[filter.friendlyName];
|
||||
if (value) {
|
||||
filter.current = value;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function updateURL(filters) {
|
||||
var current = {};
|
||||
_.each(filters, function(filter) {
|
||||
if (filter.current) {
|
||||
current[filter.friendlyName] = filter.current;
|
||||
}
|
||||
});
|
||||
|
||||
var newSearch = angular.extend($location.search(), {
|
||||
filters: angular.toJson(current)
|
||||
});
|
||||
$location.search(newSearch);
|
||||
}
|
||||
|
||||
scope.$watch('queryResult && queryResult.getFilters()', function (filters) {
|
||||
if (filters) {
|
||||
scope.filters = filters;
|
||||
|
||||
if (filters.length && false) {
|
||||
readURL();
|
||||
|
||||
// start watching for changes and update URL
|
||||
scope.$watch('filters', updateURL, true);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var VisualizationOptionsEditor = function (Visualization) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
template: Visualization.editorTemplate,
|
||||
replace: false
|
||||
}
|
||||
};
|
||||
|
||||
var Filters = function () {
|
||||
return {
|
||||
restrict: 'E',
|
||||
templateUrl: '/views/visualizations/filters.html'
|
||||
}
|
||||
}
|
||||
|
||||
var EditVisualizationForm = function (Events, Visualization, growl) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
templateUrl: '/views/visualizations/edit_visualization.html',
|
||||
replace: true,
|
||||
scope: {
|
||||
query: '=',
|
||||
queryResult: '=',
|
||||
visualization: '=?',
|
||||
openEditor: '=?',
|
||||
onNewSuccess: '=?'
|
||||
},
|
||||
link: function (scope, element, attrs) {
|
||||
scope.editRawOptions = currentUser.hasPermission('edit_raw_chart');
|
||||
scope.visTypes = Visualization.visualizationTypes;
|
||||
|
||||
scope.newVisualization = function () {
|
||||
return {
|
||||
'type': Visualization.defaultVisualization.type,
|
||||
'name': Visualization.defaultVisualization.name,
|
||||
'description': '',
|
||||
'options': Visualization.defaultVisualization.defaultOptions
|
||||
};
|
||||
}
|
||||
|
||||
if (!scope.visualization) {
|
||||
var unwatch = scope.$watch('query.id', function (queryId) {
|
||||
if (queryId) {
|
||||
unwatch();
|
||||
|
||||
scope.visualization = scope.newVisualization();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
scope.$watch('visualization.type', function (type, oldType) {
|
||||
// if not edited by user, set name to match type
|
||||
if (type && oldType != type && scope.visualization && !scope.visForm.name.$dirty) {
|
||||
// poor man's titlecase
|
||||
scope.visualization.name = scope.visualization.type[0] + scope.visualization.type.slice(1).toLowerCase();
|
||||
}
|
||||
});
|
||||
|
||||
scope.submit = function () {
|
||||
if (scope.visualization.id) {
|
||||
Events.record(currentUser, "update", "visualization", scope.visualization.id, {'type': scope.visualization.type});
|
||||
} else {
|
||||
Events.record(currentUser, "create", "visualization", null, {'type': scope.visualization.type});
|
||||
}
|
||||
|
||||
scope.visualization.query_id = scope.query.id;
|
||||
|
||||
Visualization.save(scope.visualization, function success(result) {
|
||||
growl.addSuccessMessage("Visualization saved");
|
||||
|
||||
scope.visualization = scope.newVisualization(scope.query);
|
||||
|
||||
var visIds = _.pluck(scope.query.visualizations, 'id');
|
||||
var index = visIds.indexOf(result.id);
|
||||
if (index > -1) {
|
||||
scope.query.visualizations[index] = result;
|
||||
} else {
|
||||
// new visualization
|
||||
scope.query.visualizations.push(result);
|
||||
scope.onNewSuccess && scope.onNewSuccess(result);
|
||||
}
|
||||
}, function error() {
|
||||
growl.addErrorMessage("Visualization could not be saved");
|
||||
});
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
angular.module('redash.visualization', [])
|
||||
.provider('Visualization', VisualizationProvider)
|
||||
.directive('visualizationRenderer', ['$location', 'Visualization', VisualizationRenderer])
|
||||
.directive('visualizationOptionsEditor', ['Visualization', VisualizationOptionsEditor])
|
||||
.directive('filters', Filters)
|
||||
.directive('editVisulatizationForm', ['Events', 'Visualization', 'growl', EditVisualizationForm])
|
||||
})();
|
||||
246
rd_ui/app/scripts/visualizations/chart.js
Normal file
246
rd_ui/app/scripts/visualizations/chart.js
Normal file
@@ -0,0 +1,246 @@
|
||||
(function () {
|
||||
var chartVisualization = angular.module('redash.visualization');
|
||||
|
||||
chartVisualization.config(['VisualizationProvider', function (VisualizationProvider) {
|
||||
var renderTemplate = '<chart-renderer options="visualization.options" query-result="queryResult"></chart-renderer>';
|
||||
var editTemplate = '<chart-editor></chart-editor>';
|
||||
var defaultOptions = {
|
||||
'series': {
|
||||
// 'type': 'column',
|
||||
'stacking': null
|
||||
}
|
||||
};
|
||||
|
||||
VisualizationProvider.registerVisualization({
|
||||
type: 'CHART',
|
||||
name: 'Chart',
|
||||
renderTemplate: renderTemplate,
|
||||
editorTemplate: editTemplate,
|
||||
defaultOptions: defaultOptions
|
||||
});
|
||||
}]);
|
||||
|
||||
chartVisualization.directive('chartRenderer', function () {
|
||||
return {
|
||||
restrict: 'E',
|
||||
scope: {
|
||||
queryResult: '=',
|
||||
options: '=?'
|
||||
},
|
||||
template: "<chart options='chartOptions' series='chartSeries' class='graph'></chart>",
|
||||
replace: false,
|
||||
controller: ['$scope', function ($scope) {
|
||||
$scope.chartSeries = [];
|
||||
$scope.chartOptions = {};
|
||||
|
||||
var reloadData = function(data) {
|
||||
if (!data || ($scope.queryResult && $scope.queryResult.getData()) == null) {
|
||||
$scope.chartSeries.splice(0, $scope.chartSeries.length);
|
||||
} else {
|
||||
$scope.chartSeries.splice(0, $scope.chartSeries.length);
|
||||
|
||||
_.each($scope.queryResult.getChartData($scope.options.columnMapping), function (s) {
|
||||
var additional = {'stacking': 'normal'};
|
||||
if ($scope.options.seriesOptions && $scope.options.seriesOptions[s.name]) {
|
||||
additional = $scope.options.seriesOptions[s.name];
|
||||
if (!additional.name || additional.name == "") {
|
||||
additional.name = s.name;
|
||||
}
|
||||
}
|
||||
$scope.chartSeries.push(_.extend(s, additional));
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
$scope.$watch('options', function (chartOptions) {
|
||||
if (chartOptions) {
|
||||
$scope.chartOptions = chartOptions;
|
||||
}
|
||||
});
|
||||
|
||||
$scope.$watch('options.seriesOptions', function () {
|
||||
reloadData(true);
|
||||
}, true);
|
||||
|
||||
|
||||
$scope.$watchCollection('options.columnMapping', function (chartOptions) {
|
||||
reloadData(true);
|
||||
});
|
||||
|
||||
$scope.$watch('queryResult && queryResult.getData()', function (data) {
|
||||
reloadData(data);
|
||||
});
|
||||
}]
|
||||
};
|
||||
});
|
||||
|
||||
chartVisualization.directive('chartEditor', function (ColorPalette) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
templateUrl: '/views/visualizations/chart_editor.html',
|
||||
link: function (scope, element, attrs) {
|
||||
scope.palette = ColorPalette;
|
||||
|
||||
scope.seriesTypes = {
|
||||
'Line': 'line',
|
||||
'Column': 'column',
|
||||
'Area': 'area',
|
||||
'Scatter': 'scatter',
|
||||
'Pie': 'pie'
|
||||
};
|
||||
|
||||
scope.globalSeriesType = 'column';
|
||||
|
||||
scope.stackingOptions = {
|
||||
"None": "none",
|
||||
"Normal": "normal",
|
||||
"Percent": "percent"
|
||||
};
|
||||
|
||||
scope.xAxisOptions = {
|
||||
"Date/Time": "datetime",
|
||||
"Linear": "linear",
|
||||
"Category": "category"
|
||||
};
|
||||
|
||||
scope.xAxisType = "datetime";
|
||||
scope.stacking = "none";
|
||||
|
||||
|
||||
scope.columnTypes = {
|
||||
"X": "x",
|
||||
// "X (Date time)": "x",
|
||||
// "X (Linear)": "x-linear",
|
||||
// "X (Category)": "x-category",
|
||||
"Y": "y",
|
||||
"Series": "series",
|
||||
"Unused": "unused"
|
||||
};
|
||||
|
||||
scope.series = [];
|
||||
|
||||
scope.columnTypeSelection = {};
|
||||
|
||||
var chartOptionsUnwatch = null,
|
||||
columnsWatch = null;
|
||||
|
||||
scope.$watch('globalSeriesType', function(type, old) {
|
||||
if (type && old && type !== old && scope.visualization.options.seriesOptions) {
|
||||
_.each(scope.visualization.options.seriesOptions, function(sOptions) {
|
||||
sOptions.type = type;
|
||||
});
|
||||
}
|
||||
});
|
||||
scope.$watch('visualization.type', function (visualizationType) {
|
||||
if (visualizationType == 'CHART') {
|
||||
if (scope.visualization.options.series.stacking === null) {
|
||||
scope.stacking = "none";
|
||||
} else if (scope.visualization.options.series.stacking === undefined) {
|
||||
scope.stacking = "normal";
|
||||
} else {
|
||||
scope.stacking = scope.visualization.options.series.stacking;
|
||||
}
|
||||
|
||||
var refreshSeries = function() {
|
||||
scope.series = _.map(scope.queryResult.getChartData(scope.visualization.options.columnMapping), function (s) { return s.name; });
|
||||
|
||||
// TODO: remove uneeded ones?
|
||||
if (scope.visualization.options.seriesOptions == undefined) {
|
||||
scope.visualization.options.seriesOptions = {
|
||||
type: scope.globalSeriesType
|
||||
};
|
||||
};
|
||||
|
||||
_.each(scope.series, function(s, i) {
|
||||
if (scope.visualization.options.seriesOptions[s] == undefined) {
|
||||
scope.visualization.options.seriesOptions[s] = {'type': 'column', 'yAxis': 0};
|
||||
}
|
||||
scope.visualization.options.seriesOptions[s].zIndex = i;
|
||||
|
||||
});
|
||||
scope.zIndexes = _.range(scope.series.length);
|
||||
scope.yAxes = [[0, 'left'], [1, 'right']];
|
||||
};
|
||||
|
||||
var initColumnMapping = function() {
|
||||
scope.columns = scope.queryResult.getColumns();
|
||||
|
||||
if (scope.visualization.options.columnMapping == undefined) {
|
||||
scope.visualization.options.columnMapping = {};
|
||||
}
|
||||
|
||||
scope.columnTypeSelection = scope.visualization.options.columnMapping;
|
||||
|
||||
_.each(scope.columns, function(column) {
|
||||
var definition = column.name.split("::"),
|
||||
definedColumns = _.keys(scope.visualization.options.columnMapping);
|
||||
|
||||
if (_.indexOf(definedColumns, column.name) != -1) {
|
||||
// Skip already defined columns.
|
||||
return;
|
||||
};
|
||||
|
||||
if (definition.length == 1) {
|
||||
scope.columnTypeSelection[column.name] = scope.visualization.options.columnMapping[column.name] = 'unused';
|
||||
} else if (definition == 'multi-filter') {
|
||||
scope.columnTypeSelection[column.name] = scope.visualization.options.columnMapping[column.name] = 'series';
|
||||
} else if (_.indexOf(_.values(scope.columnTypes), definition[1]) != -1) {
|
||||
scope.columnTypeSelection[column.name] = scope.visualization.options.columnMapping[column.name] = definition[1];
|
||||
} else {
|
||||
scope.columnTypeSelection[column.name] = scope.visualization.options.columnMapping[column.name] = 'unused';
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
columnsWatch = scope.$watch('queryResult.getId()', function(id) {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
|
||||
initColumnMapping();
|
||||
refreshSeries();
|
||||
});
|
||||
|
||||
scope.$watchCollection('columnTypeSelection', function(selections) {
|
||||
_.each(scope.columnTypeSelection, function(type, name) {
|
||||
scope.visualization.options.columnMapping[name] = type;
|
||||
});
|
||||
|
||||
refreshSeries();
|
||||
});
|
||||
|
||||
chartOptionsUnwatch = scope.$watch("stacking", function (stacking) {
|
||||
if (stacking == "none") {
|
||||
scope.visualization.options.series.stacking = null;
|
||||
} else {
|
||||
scope.visualization.options.series.stacking = stacking;
|
||||
}
|
||||
});
|
||||
|
||||
scope.xAxisType = (scope.visualization.options.xAxis && scope.visualization.options.xAxis.type) || scope.xAxisType;
|
||||
|
||||
xAxisUnwatch = scope.$watch("xAxisType", function (xAxisType) {
|
||||
scope.visualization.options.xAxis = scope.visualization.options.xAxis || {};
|
||||
scope.visualization.options.xAxis.type = xAxisType;
|
||||
});
|
||||
} else {
|
||||
if (chartOptionsUnwatch) {
|
||||
chartOptionsUnwatch();
|
||||
chartOptionsUnwatch = null;
|
||||
}
|
||||
|
||||
if (columnsWatch) {
|
||||
columnWatch();
|
||||
columnWatch = null;
|
||||
}
|
||||
|
||||
if (xAxisUnwatch) {
|
||||
xAxisUnwatch();
|
||||
xAxisUnwatch = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}());
|
||||
64
rd_ui/app/scripts/visualizations/cohort.js
Normal file
64
rd_ui/app/scripts/visualizations/cohort.js
Normal file
@@ -0,0 +1,64 @@
|
||||
(function () {
|
||||
var cohortVisualization = angular.module('redash.visualization');
|
||||
|
||||
cohortVisualization.config(['VisualizationProvider', function(VisualizationProvider) {
|
||||
VisualizationProvider.registerVisualization({
|
||||
type: 'COHORT',
|
||||
name: 'Cohort',
|
||||
renderTemplate: '<cohort-renderer options="visualization.options" query-result="queryResult"></cohort-renderer>'
|
||||
});
|
||||
}]);
|
||||
|
||||
cohortVisualization.directive('cohortRenderer', function() {
|
||||
return {
|
||||
restrict: 'E',
|
||||
scope: {
|
||||
queryResult: '='
|
||||
},
|
||||
template: "",
|
||||
replace: false,
|
||||
link: function($scope, element, attrs) {
|
||||
$scope.$watch('queryResult && queryResult.getData()', function (data) {
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($scope.queryResult.getData() == null) {
|
||||
|
||||
} else {
|
||||
var sortedData = _.sortBy($scope.queryResult.getData(), "date");
|
||||
var grouped = _.groupBy(sortedData, "date");
|
||||
var maxColumns = _.reduce(grouped, function(memo, data){
|
||||
return (data.length > memo)? data.length : memo;
|
||||
}, 0);
|
||||
var data = _.map(grouped, function(values, date) {
|
||||
var row = [values[0].total];
|
||||
_.each(values, function(value) { row.push(value.value); });
|
||||
_.each(_.range(values.length, maxColumns), function() { row.push(null); });
|
||||
return row;
|
||||
});
|
||||
|
||||
var initialDate = moment(sortedData[0].date).toDate(),
|
||||
container = angular.element(element)[0];
|
||||
|
||||
Cornelius.draw({
|
||||
initialDate: initialDate,
|
||||
container: container,
|
||||
cohort: data,
|
||||
title: null,
|
||||
timeInterval: 'daily',
|
||||
labels: {
|
||||
time: 'Activation Day',
|
||||
people: 'Users'
|
||||
},
|
||||
formatHeaderLabel: function (i) {
|
||||
return "Day " + (i - 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
}());
|
||||
61
rd_ui/app/scripts/visualizations/counter.js
Normal file
61
rd_ui/app/scripts/visualizations/counter.js
Normal file
@@ -0,0 +1,61 @@
|
||||
'use strict';
|
||||
|
||||
(function() {
|
||||
var module = angular.module('redash.visualization');
|
||||
|
||||
module.config(['VisualizationProvider', function(VisualizationProvider) {
|
||||
var renderTemplate =
|
||||
'<counter-renderer ' +
|
||||
'options="visualization.options" query-result="queryResult">' +
|
||||
'</counter-renderer>';
|
||||
|
||||
var editTemplate = '<counter-editor></counter-editor>';
|
||||
var defaultOptions = {};
|
||||
|
||||
VisualizationProvider.registerVisualization({
|
||||
type: 'COUNTER',
|
||||
name: 'Counter',
|
||||
renderTemplate: renderTemplate,
|
||||
editorTemplate: editTemplate,
|
||||
defaultOptions: defaultOptions
|
||||
});
|
||||
}
|
||||
]);
|
||||
|
||||
module.directive('counterRenderer', function() {
|
||||
return {
|
||||
restrict: 'E',
|
||||
templateUrl: '/views/visualizations/counter.html',
|
||||
link: function($scope, elm, attrs) {
|
||||
$scope.visualization.options.rowNumber =
|
||||
$scope.visualization.options.rowNumber || 0;
|
||||
|
||||
$scope.$watch('[queryResult && queryResult.getData(), visualization.options]',
|
||||
function() {
|
||||
var queryData = $scope.queryResult.getData();
|
||||
if (queryData) {
|
||||
var rowNumber = $scope.visualization.options.rowNumber || 0;
|
||||
var counterColName = $scope.visualization.options.counterColName || 'counter';
|
||||
var targetColName = $scope.visualization.options.targetColName || 'target';
|
||||
|
||||
$scope.counterValue = queryData[rowNumber][counterColName];
|
||||
$scope.targetValue = queryData[rowNumber][targetColName];
|
||||
|
||||
if ($scope.targetValue) {
|
||||
$scope.delta = $scope.counterValue - $scope.targetValue;
|
||||
$scope.trendPositive = $scope.delta >= 0;
|
||||
}
|
||||
}
|
||||
}, true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
module.directive('counterEditor', function() {
|
||||
return {
|
||||
restrict: 'E',
|
||||
templateUrl: '/views/visualizations/counter_editor.html'
|
||||
}
|
||||
});
|
||||
|
||||
})();
|
||||
29
rd_ui/app/scripts/visualizations/pivot.js
Normal file
29
rd_ui/app/scripts/visualizations/pivot.js
Normal file
@@ -0,0 +1,29 @@
|
||||
var renderers = angular.module('redash.renderers', []);
|
||||
|
||||
renderers.directive('pivotTableRenderer', function () {
|
||||
return {
|
||||
restrict: 'E',
|
||||
scope: {
|
||||
queryResult: '='
|
||||
},
|
||||
template: "",
|
||||
replace: false,
|
||||
link: function($scope, element, attrs) {
|
||||
$scope.$watch('queryResult && queryResult.getData()', function (data) {
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($scope.queryResult.getData() == null) {
|
||||
} else {
|
||||
// We need to give the pivot table its own copy of the data, because its change
|
||||
// it which interferes with other visualizations.
|
||||
var data = $.extend(true, [], $scope.queryResult.getData());
|
||||
$(element).pivotUI(data, {
|
||||
renderers: $.pivotUtilities.renderers
|
||||
}, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
109
rd_ui/app/scripts/visualizations/table.js
Normal file
109
rd_ui/app/scripts/visualizations/table.js
Normal file
@@ -0,0 +1,109 @@
|
||||
(function () {
|
||||
var tableVisualization = angular.module('redash.visualization');
|
||||
|
||||
tableVisualization.config(['VisualizationProvider', function (VisualizationProvider) {
|
||||
VisualizationProvider.registerVisualization({
|
||||
type: 'TABLE',
|
||||
name: 'Table',
|
||||
renderTemplate: '<grid-renderer options="visualization.options" query-result="queryResult"></grid-renderer>',
|
||||
skipTypes: true
|
||||
});
|
||||
}]);
|
||||
|
||||
tableVisualization.directive('gridRenderer', function () {
|
||||
return {
|
||||
restrict: 'E',
|
||||
scope: {
|
||||
queryResult: '=',
|
||||
itemsPerPage: '='
|
||||
},
|
||||
templateUrl: "/views/grid_renderer.html",
|
||||
replace: false,
|
||||
controller: ['$scope', '$filter', function ($scope, $filter) {
|
||||
$scope.gridColumns = [];
|
||||
$scope.gridData = [];
|
||||
$scope.gridConfig = {
|
||||
isPaginationEnabled: true,
|
||||
itemsByPage: $scope.itemsPerPage || 15,
|
||||
maxSize: 8
|
||||
};
|
||||
|
||||
$scope.$watch('queryResult && queryResult.getData()', function (data) {
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($scope.queryResult.getData() == null) {
|
||||
$scope.gridColumns = [];
|
||||
$scope.gridData = [];
|
||||
$scope.filters = [];
|
||||
} else {
|
||||
$scope.filters = $scope.queryResult.getFilters();
|
||||
|
||||
var prepareGridData = function (data) {
|
||||
var gridData = _.map(data, function (row) {
|
||||
var newRow = {};
|
||||
_.each(row, function (val, key) {
|
||||
newRow[$scope.queryResult.getColumnCleanName(key)] = val;
|
||||
})
|
||||
return newRow;
|
||||
});
|
||||
|
||||
return gridData;
|
||||
};
|
||||
|
||||
$scope.gridData = prepareGridData($scope.queryResult.getData());
|
||||
|
||||
var columns = $scope.queryResult.getColumns();
|
||||
$scope.gridColumns = _.map($scope.queryResult.getColumnCleanNames(), function (col, i) {
|
||||
var columnDefinition = {
|
||||
'label': $scope.queryResult.getColumnFriendlyNames()[i],
|
||||
'map': col
|
||||
};
|
||||
|
||||
var columnType = columns[i].type;
|
||||
|
||||
if (columnType === 'integer') {
|
||||
columnDefinition.formatFunction = 'number';
|
||||
columnDefinition.formatParameter = 0;
|
||||
} else if (columnType === 'float') {
|
||||
columnDefinition.formatFunction = 'number';
|
||||
columnDefinition.formatParameter = 2;
|
||||
} else if (columnType === 'boolean') {
|
||||
columnDefinition.formatFunction = function (value) {
|
||||
if (value !== undefined) {
|
||||
return "" + value;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
} else if (columnType === 'date') {
|
||||
columnDefinition.formatFunction = function (value) {
|
||||
if (value) {
|
||||
return value.format("DD/MM/YY");
|
||||
}
|
||||
return value;
|
||||
};
|
||||
} else if (columnType === 'datetime') {
|
||||
columnDefinition.formatFunction = function (value) {
|
||||
if (value) {
|
||||
return value.format("DD/MM/YY HH:mm");
|
||||
}
|
||||
return value;
|
||||
};
|
||||
} else {
|
||||
columnDefinition.formatFunction = function (value) {
|
||||
if (angular.isString(value)) {
|
||||
value = $filter('linkify')(value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return columnDefinition;
|
||||
});
|
||||
}
|
||||
});
|
||||
}]
|
||||
}
|
||||
})
|
||||
}());
|
||||
40
rd_ui/app/styles/login.css
Normal file
40
rd_ui/app/styles/login.css
Normal file
@@ -0,0 +1,40 @@
|
||||
.main {
|
||||
max-width: 320px;
|
||||
margin: 0 auto;
|
||||
margin-top:20px;
|
||||
}
|
||||
|
||||
.login-or {
|
||||
position: relative;
|
||||
font-size: 18px;
|
||||
color: #aaa;
|
||||
margin-top: 20px;
|
||||
margin-bottom: 20px;
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.span-or {
|
||||
display: block;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: -2px;
|
||||
margin-left: -25px;
|
||||
background-color: #fff;
|
||||
width: 50px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hr-or {
|
||||
background-color: #cdcdcd;
|
||||
height: 1px;
|
||||
margin-top: 0px !important;
|
||||
margin-bottom: 0px !important;
|
||||
}
|
||||
|
||||
img.login-button {
|
||||
width: 250px;
|
||||
display: block;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
@@ -25,7 +25,33 @@ a.navbar-brand {
|
||||
margin-top: 5px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.avatar img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
#logout {
|
||||
color: white;
|
||||
position: relative;
|
||||
left: -9px;
|
||||
bottom: -11px;
|
||||
}
|
||||
|
||||
.details-toggle {
|
||||
cursor: pointer;
|
||||
}
|
||||
.details-toggle::before {
|
||||
content: '▸';
|
||||
margin-right: 5px;
|
||||
}
|
||||
.details-toggle.open::before {
|
||||
content: '▾';
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.edit-in-place span {
|
||||
white-space: pre-line;
|
||||
}
|
||||
.edit-in-place span.editable {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -60,10 +86,15 @@ a.navbar-brand {
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.panel-heading > a {
|
||||
.panel-heading > a,
|
||||
.panel-heading .query-link {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.panel-heading .query-link:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* angular-growl */
|
||||
.growl {
|
||||
position: fixed;
|
||||
@@ -213,4 +244,76 @@ to add those CSS styles here. */
|
||||
color: white;
|
||||
background-color: #FF8080;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
.nav-tabs > li.rd-tab-btn {
|
||||
float: right;
|
||||
}
|
||||
|
||||
/* light version of bootstrap's form-control */
|
||||
.rd-form-control {
|
||||
display: block;
|
||||
padding: 6px 12px;
|
||||
line-height: 1.428571429;
|
||||
color: #555555;
|
||||
vertical-align: middle;
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #cccccc;
|
||||
border-radius: 4px;
|
||||
-webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
|
||||
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
|
||||
-webkit-transition: border-color ease-in-out 0.15s, box-shadow ease-in-out 0.15s;
|
||||
transition: border-color ease-in-out 0.15s, box-shadow ease-in-out 0.15s;
|
||||
}
|
||||
.rd-form-control {
|
||||
width: 100%;
|
||||
}
|
||||
pivot-table-renderer > table, grid-renderer > div, visualization-renderer > div {
|
||||
overflow: auto;
|
||||
}
|
||||
counter-renderer {
|
||||
display: block;
|
||||
text-align: center;
|
||||
}
|
||||
counter-renderer counter {
|
||||
margin: 0 auto;
|
||||
background: #f9f9f9;
|
||||
padding: 15px 50px;
|
||||
display: block;;
|
||||
}
|
||||
counter-renderer value,
|
||||
counter-renderer counter-target {
|
||||
font-size: 80px;
|
||||
display: block;
|
||||
}
|
||||
counter-renderer counter-target {
|
||||
color: #ccc;
|
||||
}
|
||||
counter-renderer counter.positive value {
|
||||
color: #5cb85c;
|
||||
}
|
||||
counter-renderer counter.negative value {
|
||||
color: #d9534f;
|
||||
margin-right: 15px;
|
||||
}
|
||||
counter-renderer counter-name {
|
||||
font-size: 40px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.rd-widget-textbox p {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.iframe-container {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/*
|
||||
bootstrap's hidden-xs class adds display:block when not hidden
|
||||
use this class when you need to keep the original display value
|
||||
*/
|
||||
@media (max-width: 767px) {
|
||||
.rd-hidden-xs {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
BIN
rd_ui/app/styles/select2-spinner.gif
Normal file
BIN
rd_ui/app/styles/select2-spinner.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
BIN
rd_ui/app/styles/select2.png
Normal file
BIN
rd_ui/app/styles/select2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 613 B |
BIN
rd_ui/app/styles/select2x2.png
Normal file
BIN
rd_ui/app/styles/select2x2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 845 B |
@@ -21,30 +21,20 @@
|
||||
Started
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
<span class="badge">{{manager.queue_size}}</span>
|
||||
Queue Size
|
||||
<span class="badge">{{manager.outdated_queries_count}}</span>
|
||||
Outdated Queries Count
|
||||
</li>
|
||||
|
||||
<li class="list-group-item" ng-if="flowerUrl">
|
||||
<a href="/admin/workers">Workers' Status</a>
|
||||
</li>
|
||||
</ul>
|
||||
<ul class="list-group col-lg-4">
|
||||
<div ng-repeat="worker in workers">
|
||||
<li class="list-group-item active">Worker {{$index+1}}</li>
|
||||
<li class="list-group-item">
|
||||
<span class="badge" am-time-ago="worker.updated_at*1000.0"></span>
|
||||
Updated
|
||||
<li class="list-group-item active">Queues</li>
|
||||
<li class="list-group-item" ng-repeat="(name, value) in manager.queues">
|
||||
<span class="badge">{{value.size}}</span>
|
||||
{{name}} ({{value.data_sources}})
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
<span class="badge" am-time-ago="worker.started_at*1000.0"></span>
|
||||
Started
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
<span class="badge">{{worker.jobs_count}}</span>
|
||||
Jobs Received
|
||||
</li>
|
||||
<li class="list-group-item">
|
||||
<span class="badge">{{worker.done_jobs_count}}</span>
|
||||
Jobs Done
|
||||
</li>
|
||||
</div>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="panel-footer">Next refresh: <span am-time-ago="refresh_time"></span></div>
|
||||
|
||||
3
rd_ui/app/views/admin_workers.html
Normal file
3
rd_ui/app/views/admin_workers.html
Normal file
@@ -0,0 +1,3 @@
|
||||
<div class="container-fluid iframe-container">
|
||||
<iframe src="{{flowerUrl}}" style="width:100%; height:100%; background-color:transparent;"></iframe>
|
||||
</div>
|
||||
@@ -4,6 +4,8 @@
|
||||
<div class="container">
|
||||
<h2 id="dashboard_title">
|
||||
{{dashboard.name}}
|
||||
|
||||
<button type="button" class="btn btn-default btn-xs" ng-class="{active: refreshEnabled}" tooltip="Enable/Disable Auto Refresh" ng-click="triggerRefresh()"><span class="glyphicon glyphicon-refresh"></span></button>
|
||||
<span ng-show="dashboard.canEdit()">
|
||||
<button type="button" class="btn btn-default btn-xs" data-toggle="modal" href="#edit_dashboard_dialog" tooltip="Edit Dashboard (Name/Layout)"><span
|
||||
class="glyphicon glyphicon-cog"></span></button>
|
||||
@@ -12,6 +14,7 @@
|
||||
</button>
|
||||
</span>
|
||||
</h2>
|
||||
<filters></filters>
|
||||
</div>
|
||||
|
||||
<div class="container" id="dashboard">
|
||||
@@ -19,13 +22,14 @@
|
||||
<div ng-repeat="widget in row" class="col-lg-{{widget.width | colWidth}}"
|
||||
ng-controller='WidgetCtrl'>
|
||||
|
||||
<div class="panel panel-default">
|
||||
<div class="panel panel-default" ng-if="type=='visualization'">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title" style="cursor: pointer;" ng-click="open(query)">
|
||||
<h3 class="panel-title">
|
||||
<p>
|
||||
<span ng-bind="query.name"></span>
|
||||
<span ng-hide="currentUser.hasPermission('view_query')">{{query.name}}</span>
|
||||
<query-link query="query" visualization="widget.visualization" ng-show="currentUser.hasPermission('view_query')"></query-link>
|
||||
</p>
|
||||
<div class="text-muted" ng-bind="query.description"></div>
|
||||
<div class="text-muted" ng-bind-html="query.description | markdown"></div>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
@@ -37,12 +41,32 @@
|
||||
tooltip-placement="bottom">Updated: <span am-time-ago="queryResult.getUpdatedAt()"></span></span>
|
||||
|
||||
<span class="pull-right">
|
||||
<a class="btn btn-default btn-xs" ng-href="/queries/{{query.id}}"><span class="glyphicon glyphicon-link"></span></a>
|
||||
<a class="btn btn-default btn-xs" ng-href="/queries/{{query.id}}#{{widget.visualization.id}}" ng-show="currentUser.hasPermission('view_query')"><span class="glyphicon glyphicon-link"></span></a>
|
||||
<button type="button" class="btn btn-default btn-xs" ng-show="dashboard.canEdit()" ng-click="deleteWidget()" title="Remove Widget"><span class="glyphicon glyphicon-trash"></span></button>
|
||||
</span>
|
||||
|
||||
<span class="pull-right">
|
||||
<a class="btn btn-default btn-xs" ng-disabled="!queryResult.getData()" query-result-link target="_self">
|
||||
<span class="glyphicon glyphicon-cloud-download"></span>
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel panel-default rd-widget-textbox" ng-if="type=='textbox'" ng-mouseenter="showControls = true" ng-mouseleave="showControls = false">
|
||||
<div class="panel-body">
|
||||
<div class="row">
|
||||
<div class="col-lg-11">
|
||||
<p ng-bind-html="widget.text | markdown"></p>
|
||||
</div>
|
||||
<div class="col-lg-1">
|
||||
<span class="pull-right" ng-show="showControls">
|
||||
<button type="button" class="btn btn-default btn-xs" ng-show="dashboard.canEdit()" ng-click="deleteWidget()" title="Remove Widget"><span class="glyphicon glyphicon-trash"></span></button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
<form role="form" name="visForm" ng-submit="submit()">
|
||||
<div class="form-group">
|
||||
<label class="control-label">Name</label>
|
||||
<input type="text" class="form-control" ng-model="vis.name" placeholder="{{query.name}}">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="control-label">Description</label>
|
||||
<textarea class="form-control" ng-model="vis.description" placeholder="{{query.description}}"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="control-label">Visualization Type</label>
|
||||
<select required ng-model="vis.type" ng-options="value as key for (key, value) in visTypes" class="form-control" ng-change="typeChanged()"></select>
|
||||
</div>
|
||||
|
||||
<div class="form-group" ng-show="vis.type == visTypes.Chart">
|
||||
<label class="control-label">Chart Type</label>
|
||||
<select required ng-model="vis.options.series.type" ng-options="value as key for (key, value) in seriesTypes" class="form-control"></select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<a class="link" ng-click="toggleAdvancedMode()">Advanced</a>
|
||||
<textarea json-text class="form-control" rows="5" ng-model="vis.options" ng-show="advancedMode"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
@@ -1,17 +1,4 @@
|
||||
<div>
|
||||
<div class="btn-group pull-right" ng-repeat="filter in filters">
|
||||
<button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown">
|
||||
{{filter.friendlyName}}: {{filter.current}}<span class="caret"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu" role="menu">
|
||||
<li ng-repeat="value in filter.values">
|
||||
<a href="#" ng-click="filter.current = value">{{value}}</a>
|
||||
</li>
|
||||
<li class="divider"></li>
|
||||
<li><a href="#" ng-click="filter.current = 'All'">All</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<smart-table rows="gridData" columns="gridColumns"
|
||||
config="gridConfig"
|
||||
class="table table-condensed table-hover"></smart-table>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="list-group" ng-repeat="(name, dashboards) in allDashboards">
|
||||
<div class="list-group-item active">
|
||||
{{name}}
|
||||
<button type="button" class="btn btn-sm btn-link" data-toggle="modal" href="#new_dashboard_dialog" tooltip="New Dashboard"><span class="glyphicon glyphicon-plus-sign"></span></button>
|
||||
<button ng-show="currentUser.hasPermission('create_dashboard')" type="button" class="btn btn-sm btn-link" data-toggle="modal" href="#new_dashboard_dialog" tooltip="New Dashboard"><span class="glyphicon glyphicon-plus-sign"></span></button>
|
||||
</div>
|
||||
<div class="list-group-item" ng-repeat="dashboard in dashboards" >
|
||||
<button type="button" class="close delete-button" aria-hidden="true" ng-show="dashboard.canEdit()" ng-click="archiveDashboard(dashboard)" tooltip="Delete Dashboard">×</button>
|
||||
@@ -11,7 +11,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ng-show="currentUser.is_admin">
|
||||
<div ng-show="currentUser.hasPermission('admin')">
|
||||
<div class="list-group">
|
||||
<div class="list-group-item active">Admin</div>
|
||||
<a href="/admin/status" class="list-group-item">Status</a>
|
||||
|
||||
@@ -6,42 +6,54 @@
|
||||
<h4 class="modal-title">Add Widget</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>
|
||||
<form class="form-inline" role="form" ng-submit="loadVisualizations()">
|
||||
<div class="form-group">
|
||||
<input class="form-control" placeholder="Query Id" ng-model="queryId">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" ng-disabled="!queryId">
|
||||
<span class="glyphicon glyphicon-refresh"></span> Load
|
||||
</button>
|
||||
</form>
|
||||
<p class="btn-group">
|
||||
<button type="button" class="btn btn-default" ng-class="{active: isVisualization()}" ng-click="setType('visualization')">Visualization</button>
|
||||
<button type="button" class="btn btn-default" ng-class="{active: isTextBox()}" ng-click="setType('textbox')">Text Box</button>
|
||||
</p>
|
||||
|
||||
<div ng-show="query">
|
||||
<div class="form-group">
|
||||
<label for="">Choose Visualation</label>
|
||||
<select ng-model="selectedVis" ng-options="vis as vis.name group by vis.type for vis in query.visualizations" class="form-control"></select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<a ng-click="toggleView('addNew')" class="link">+ Add New</a>
|
||||
<div class="well" ng-show="currentView=='addNew'">
|
||||
<edit-visulatization-form query="query"></edit-visulatization-form>
|
||||
<div ng-show="isTextBox()">
|
||||
<div class="form-group">
|
||||
<textarea class="form-control" ng-model="text" rows="3"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div ng-show="text">
|
||||
<strong>Preview:</strong>
|
||||
<p ng-bind-html="text | markdown"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="">Widget Size</label>
|
||||
<select class="form-control" ng-model="widgetSize" ng-options="c.value as c.name for c in widgetSizes"></select>
|
||||
</div>
|
||||
<div ng-show="isVisualization()">
|
||||
<div class="form-group">
|
||||
<ui-select ng-model="query.selected" theme="bootstrap" reset-search-input="false">
|
||||
<ui-select-match placeholder="Search a query by name">{{$select.selected.name}}</ui-select-match>
|
||||
<ui-select-choices repeat="q in queries"
|
||||
refresh="searchQueries($select.search)"
|
||||
refresh-delay="0">
|
||||
<div ng-bind-html="q.name | highlight: $select.search | trustAsHtml"></div>
|
||||
</ui-select-choices>
|
||||
</ui-select>
|
||||
</div>
|
||||
|
||||
<div ng-show="selected_query">
|
||||
<div class="form-group">
|
||||
<label for="">Choose Visualization</label>
|
||||
<select ng-model="selectedVis" ng-options="vis as vis.name group by vis.type for vis in selected_query.visualizations" class="form-control"></select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="">Widget Size</label>
|
||||
<select class="form-control" ng-model="widgetSize"
|
||||
ng-options="c.value as c.name for c in widgetSizes"></select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-default" ng-disabled="saveInProgress" data-dismiss="modal">Close</button>
|
||||
<button type="button" class="btn btn-primary" ng-disabled="saveInProgress" ng-click="saveWidget()">Add to Dashboard</button>
|
||||
<button type="button" class="btn btn-primary" ng-disabled="saveInProgress || !(selectedVis || isTextBox())" ng-click="saveWidget()">Add to Dashboard</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- /.modal-content -->
|
||||
</div>
|
||||
<!-- /.modal-dialog -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
19
rd_ui/app/views/queries_search_results.html
Normal file
19
rd_ui/app/views/queries_search_results.html
Normal file
@@ -0,0 +1,19 @@
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<p>
|
||||
<form class="form-inline" role="form" ng-submit="search()">
|
||||
<div class="form-group">
|
||||
<input class="form-control" placeholder="Search..." ng-model="term" autofocus>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<span class="glyphicon glyphicon-search"></span>
|
||||
</button>
|
||||
</form>
|
||||
</p>
|
||||
|
||||
<smart-table rows="queries" columns="gridColumns"
|
||||
config="gridConfig"
|
||||
class="table table-condensed table-hover"></smart-table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
190
rd_ui/app/views/query.html
Normal file
190
rd_ui/app/views/query.html
Normal file
@@ -0,0 +1,190 @@
|
||||
|
||||
<div class="container">
|
||||
|
||||
<alert-unsaved-changes ng-if="canEdit" is-dirty="isDirty"></alert-unsaved-changes>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-12">
|
||||
<div class="row">
|
||||
<div class="col-lg-10">
|
||||
<h2>
|
||||
<edit-in-place editable="isQueryOwner" done="saveName" ignore-blanks='true' value="query.name"></edit-in-place>
|
||||
</h2>
|
||||
<p>
|
||||
<em>
|
||||
<edit-in-place editable="isQueryOwner"
|
||||
done="saveDescription"
|
||||
editor="textarea"
|
||||
placeholder="No description"
|
||||
ignore-blanks='false'
|
||||
value="query.description"
|
||||
markdown="true">
|
||||
</edit-in-place>
|
||||
</em>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-2">
|
||||
<div class="rd-hidden-xs pull-right">
|
||||
<query-source-link></query-source-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="visible-xs">
|
||||
<p>
|
||||
<span class="text-muted">Last update </span>
|
||||
<strong>
|
||||
<rd-time-ago value="queryResult.query_result.retrieved_at"></rd-time-ago>
|
||||
</strong>
|
||||
|
||||
<span class="text-muted">Created By </span>
|
||||
<strong ng-hide="isQueryOwner">{{query.user.name}}</strong>
|
||||
<strong ng-show="isQueryOwner">You</strong>
|
||||
|
||||
<span class="text-muted">Runtime </span>
|
||||
<strong ng-show="!queryExecuting">{{queryResult.getRuntime() | durationHumanize}}</strong>
|
||||
<span ng-show="queryExecuting">Running…</span>
|
||||
|
||||
<span class="text-muted">Rows </span>
|
||||
<strong>{{queryResult.getData().length}}</strong>
|
||||
</p>
|
||||
<p>
|
||||
<query-source-link></query-source-link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-12">
|
||||
<div ng-show="sourceMode">
|
||||
<p>
|
||||
<button type="button" class="btn btn-primary btn-xs" ng-disabled="queryExecuting" ng-click="executeQuery()">
|
||||
<span class="glyphicon glyphicon-play"></span> Execute
|
||||
</button>
|
||||
<query-formatter></query-formatter>
|
||||
<span class="pull-right">
|
||||
<button class="btn btn-xs btn-default rd-hidden-xs" ng-click="duplicateQuery()">
|
||||
<span class="glyphicon glyphicon-share-alt"></span> Fork
|
||||
</button>
|
||||
|
||||
<button class="btn btn-success btn-xs" ng-show="canEdit" ng-click="saveQuery()">
|
||||
<span class="glyphicon glyphicon-floppy-disk"> </span> Save<span ng-show="isDirty">*</span>
|
||||
</button>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- code editor -->
|
||||
<div ng-show="sourceMode">
|
||||
<p>
|
||||
<query-editor query="query" lock="queryExecuting"></query-editor>
|
||||
</p>
|
||||
<hr>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-3 rd-hidden-xs">
|
||||
<p>
|
||||
<span class="glyphicon glyphicon-time"></span>
|
||||
<span class="text-muted">Last update </span>
|
||||
<strong>
|
||||
<rd-time-ago value="queryResult.query_result.retrieved_at"></rd-time-ago>
|
||||
</strong>
|
||||
</p>
|
||||
<p>
|
||||
<span class="glyphicon glyphicon-user"></span>
|
||||
<span class="text-muted">Created By </span>
|
||||
<strong ng-hide="isQueryOwner">{{query.user.name}}</strong>
|
||||
<strong ng-show="isQueryOwner">You</strong>
|
||||
</p>
|
||||
<p>
|
||||
<span class="glyphicon glyphicon-play"></span>
|
||||
<span class="text-muted">Runtime </span>
|
||||
<strong ng-show="!queryExecuting">{{queryResult.getRuntime() | durationHumanize}}</strong>
|
||||
<span ng-show="queryExecuting">Running…</span>
|
||||
</p>
|
||||
<p>
|
||||
<span class="glyphicon glyphicon-align-justify"></span>
|
||||
<span class="text-muted">Rows </span><strong>{{queryResult.getData().length}}</strong>
|
||||
</p>
|
||||
<p>
|
||||
<span class="glyphicon glyphicon-refresh"></span>
|
||||
<span class="text-muted">Refresh Interval</span>
|
||||
<query-refresh-select></query-refresh-select>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<span class="glyphicon glyphicon-hdd"></span>
|
||||
<span class="text-muted">Data Source</span>
|
||||
<select ng-disabled="!isQueryOwner" ng-model="query.data_source_id" ng-change="updateDataSource()" ng-options="ds.id as ds.name for ds in dataSources"></select>
|
||||
</p>
|
||||
|
||||
<hr>
|
||||
|
||||
<p>
|
||||
<a class="btn btn-primary btn-sm" ng-disabled="queryExecuting || !queryResult.getData()" query-result-link target="_self">
|
||||
<span class="glyphicon glyphicon-cloud-download"></span>
|
||||
<span class="rd-hidden-xs">Download Dataset</span>
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-9">
|
||||
<!-- alerts -->
|
||||
<div class="alert alert-info" ng-show="queryResult.getStatus() == 'processing'">
|
||||
Executing query… <rd-timer timestamp="queryResult.getUpdatedAt()"></rd-timer>
|
||||
<button type="button" class="btn btn-warning btn-xs pull-right" ng-disabled="cancelling" ng-click="cancelExecution()">Cancel</button>
|
||||
</div>
|
||||
<div class="alert alert-info" ng-show="queryResult.getStatus() == 'waiting'">
|
||||
Query in queue… <rd-timer timestamp="queryResult.getUpdatedAt()"></rd-timer>
|
||||
<button type="button" class="btn btn-warning btn-xs pull-right" ng-disabled="cancelling" ng-click="cancelExecution()">Cancel</button>
|
||||
</div>
|
||||
<div class="alert alert-danger" ng-show="queryResult.getError()">Error running query: <strong>{{queryResult.getError()}}</strong></div>
|
||||
|
||||
<!-- tabs and data -->
|
||||
<div ng-show="showDataset">
|
||||
<div class="row">
|
||||
<div class="col-lg-12">
|
||||
<ul class="nav nav-tabs">
|
||||
<rd-tab tab-id="table" name="Table"></rd-tab>
|
||||
<rd-tab tab-id="pivot" name="Pivot Table"></rd-tab>
|
||||
<rd-tab tab-id="{{vis.id}}" name="{{vis.name}}" ng-if="vis.type!='TABLE'" ng-repeat="vis in query.visualizations">
|
||||
<span class="remove" ng-click="deleteVisualization($event, vis)" ng-show="canEdit"> ×</span>
|
||||
</rd-tab>
|
||||
<rd-tab tab-id="add" name="+ New" removeable="true" ng-show="canEdit"></rd-tab>
|
||||
<li ng-if="!sourceMode" class="rd-tab-btn"><button class="btn btn-sm btn-default" ng-click="executeQuery()" ng-disabled="queryExecuting" title="Refresh Dataset"><span class="glyphicon glyphicon-refresh"></span></button></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-lg-12">
|
||||
<div ng-show="selectedTab == 'table'" >
|
||||
<filters></filters>
|
||||
<grid-renderer query-result="queryResult" items-per-page="50"></grid-renderer>
|
||||
</div>
|
||||
|
||||
<pivot-table-renderer ng-show="selectedTab == 'pivot'" query-result="queryResult"></pivot-table-renderer>
|
||||
|
||||
<div ng-show="selectedTab == vis.id" ng-repeat="vis in query.visualizations">
|
||||
<visualization-renderer visualization="vis" query-result="queryResult"></visualization-renderer>
|
||||
|
||||
<edit-visulatization-form visualization="vis" query="query" query-result="queryResult" ng-show="canEdit"></edit-visulatization-form>
|
||||
</div>
|
||||
|
||||
<div ng-if="canEdit" ng-show="selectedTab == 'add'">
|
||||
<visualization-renderer visualization="newVisualization" query-result="queryResult"></visualization-renderer>
|
||||
<edit-visulatization-form visualization="newVisualization" query="query" query-result="queryResult" ng-show="canEdit" open-editor="true" on-new-success="setVisualizationTab"></edit-visulatization-form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,97 +0,0 @@
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<h3 class="panel-title">
|
||||
<p>
|
||||
<edit-in-place editable="currentUser.canEdit(query)" ignore-blanks='true' value="query.name"></edit-in-place>
|
||||
</p>
|
||||
</h3>
|
||||
<p>
|
||||
<edit-in-place editable="currentUser.canEdit(query)" editor="textarea" placeholder="No description" ignore-blanks='false' value="query.description" class="text-muted"></edit-in-place>
|
||||
</p>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<textarea ui-codemirror="editorOptions" ng-model="query.query"></textarea>
|
||||
|
||||
<div>
|
||||
<a class="btn btn-default" ng-disabled="queryExecuting || !queryResult.getData()" ng-href="{{dataUri}}" download="{{dataFilename}}" target="_self">
|
||||
<span class="glyphicon glyphicon-floppy-disk"></span> Download Data Set
|
||||
</a>
|
||||
<button type="button" class="btn btn-default center-x" ng-click="formatQuery()"><span class="glyphicon glyphicon-ok"></span> Format SQL</button>
|
||||
|
||||
<div class="btn-group pull-right">
|
||||
<button type="button" class="btn btn-default" ng-click="duplicateQuery()">Duplicate</button>
|
||||
<button type="button" class="btn btn-default" ng-disabled="!currentUser.canEdit(query)" ng-click="saveQuery()">Save
|
||||
<span ng-show="dirty">*</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" ng-disabled="queryExecuting" ng-click="executeQuery()">Execute</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="panel-footer">
|
||||
<span ng-show="queryResult.getRuntime()>=0">Query runtime: {{queryResult.getRuntime() | durationHumanize}} | </span>
|
||||
<span ng-show="queryResult.query_result.retrieved_at">Last update time: <span am-time-ago="queryResult.query_result.retrieved_at"></span> | </span>
|
||||
<span ng-show="queryResult.getStatus() == 'done'">Rows: {{queryResult.getData().length}} | </span>
|
||||
Created by: {{query.user}}
|
||||
<div class="pull-right">Refresh query: <select ng-model="query.ttl" ng-options="c.value as c.name for c in refreshOptions"></select><br></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info" ng-show="queryResult.getStatus() == 'processing'">
|
||||
Executing query... <rd-timer timestamp="queryResult.getUpdatedAt()"></rd-timer>
|
||||
<button type="button" class="btn btn-warning btn-xs pull-right" ng-disabled="cancelling" ng-click="cancelExecution()">Cancel</button>
|
||||
</div>
|
||||
<div class="alert alert-info" ng-show="queryResult.getStatus() == 'waiting'">
|
||||
Query in queue... <rd-timer timestamp="queryResult.getUpdatedAt()"></rd-timer>
|
||||
<button type="button" class="btn btn-warning btn-xs pull-right" ng-disabled="cancelling" ng-click="cancelExecution()">Cancel</button>
|
||||
</div>
|
||||
<div class="alert alert-danger" ng-show="queryResult.getError()">Error running query: <strong>{{queryResult.getError()}}</strong></div>
|
||||
</div>
|
||||
|
||||
<div class="row" ng-show="queryResult.getStatus() == 'done'">
|
||||
<ul class="nav nav-tabs">
|
||||
<rd-tab id="table" name="Table"></rd-tab>
|
||||
<rd-tab id="pivot" name="Pivot Table"></rd-tab>
|
||||
<rd-tab id="{{vis.id}}" name="{{vis.name}}" ng-repeat="vis in query.visualizations">
|
||||
<span class="remove" ng-click="deleteVisualization($event, vis)"> ×</span>
|
||||
</rd-tab>
|
||||
<rd-tab id="add" name="+New" removeable="true"></rd-tab>
|
||||
</ul>
|
||||
|
||||
<div class="col-lg-12" ng-show="selectedTab == 'table'">
|
||||
<grid-renderer query-result="queryResult" items-per-page="50"></grid-renderer>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-12" ng-show="selectedTab == 'pivot'">
|
||||
<pivot-table-renderer query-result="queryResult"></pivot-table-renderer>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-12" ng-show="selectedTab == vis.id" ng-repeat="vis in query.visualizations">
|
||||
<div class="row">
|
||||
<p>
|
||||
<div class="col-lg-6">
|
||||
<edit-visulatization-form vis="vis" query="query"></edit-visulatization-form>
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<visualization-renderer visualization="vis" query-result="queryResult"></visualization-renderer>
|
||||
</div>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-12" ng-show="selectedTab == 'add'">
|
||||
<div class="row">
|
||||
<p>
|
||||
<div class="col-lg-6">
|
||||
<edit-visulatization-form vis="newVisualization" query="query"></edit-visulatization-form>
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<visualization-renderer visualization="newVisualization" query-result="queryResult"></visualization-renderer>
|
||||
</div>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
107
rd_ui/app/views/visualizations/chart_editor.html
Normal file
107
rd_ui/app/views/visualizations/chart_editor.html
Normal file
@@ -0,0 +1,107 @@
|
||||
<div class="form-horizontal">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-body">
|
||||
<div class="form-group">
|
||||
<label class="control-label col-sm-2">Stacking</label>
|
||||
|
||||
<div class="col-sm-10">
|
||||
<select required ng-model="stacking"
|
||||
ng-options="value as key for (key, value) in stackingOptions"
|
||||
class="form-control"></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label col-sm-2">X Axis Type</label>
|
||||
|
||||
<div class="col-sm-10">
|
||||
<select required ng-model="xAxisType" ng-options="value as key for (key, value) in xAxisOptions"
|
||||
class="form-control"></select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="control-label col-sm-2">Series Type</label>
|
||||
|
||||
<div class="col-sm-10">
|
||||
<select required ng-options="value as key for (key, value) in seriesTypes"
|
||||
ng-model="globalSeriesType" class="form-control"></select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-6">
|
||||
<div class="list-group">
|
||||
<div class="list-group-item active">
|
||||
Columns Mapping
|
||||
</div>
|
||||
<div class="list-group-item">
|
||||
<div class="form-group" ng-repeat="column in columns">
|
||||
<label class="control-label col-sm-4">{{column.name}}</label>
|
||||
|
||||
<div class="col-sm-8">
|
||||
<select ng-options="value as key for (key, value) in columnTypes" class="form-control"
|
||||
ng-model="columnTypeSelection[column.name]"></select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-6" ng-if="series.length > 0">
|
||||
<div class="list-group" ng-repeat="seriesName in series">
|
||||
<div class="list-group-item active">
|
||||
{{seriesName}}
|
||||
</div>
|
||||
<div class="list-group-item">
|
||||
<div class="form-group">
|
||||
<label class="control-label col-sm-3">Type</label>
|
||||
|
||||
<div class="col-sm-9">
|
||||
<select required ng-model="visualization.options.seriesOptions[seriesName].type"
|
||||
ng-options="value as key for (key, value) in seriesTypes"
|
||||
class="form-control"></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label col-sm-3">zIndex</label>
|
||||
|
||||
<div class="col-sm-9">
|
||||
<select required ng-model="visualization.options.seriesOptions[seriesName].zIndex"
|
||||
ng-options="o as o for o in zIndexes"
|
||||
class="form-control"></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label col-sm-3">y Axis</label>
|
||||
|
||||
<div class="col-sm-9">
|
||||
<select required ng-model="visualization.options.seriesOptions[seriesName].yAxis"
|
||||
ng-options="o[0] as o[1] for o in yAxes"
|
||||
class="form-control"></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label col-sm-3">Name</label>
|
||||
|
||||
<div class="col-sm-9">
|
||||
<input name="seriesName" type="text" class="form-control"
|
||||
ng-model="visualization.options.seriesOptions[seriesName].name"
|
||||
placeholder="{{seriesName}}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label col-sm-3">Color</label>
|
||||
|
||||
<div class="col-sm-9">
|
||||
<select class="form-control" ng-model="visualization.options.seriesOptions[seriesName].color" ng-options="val as key for (key,val) in palette"></select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
15
rd_ui/app/views/visualizations/cohort_editor.html
Normal file
15
rd_ui/app/views/visualizations/cohort_editor.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<div class="form-group">
|
||||
<label class="control-label">Time Label</label>
|
||||
<input type="text" class="form-control" ng-model="cohortOptions.timeLabel">
|
||||
<label class="control-label">People Label</label>
|
||||
<input type="text" class="form-control" ng-model="cohortOptions.peopleLabel">
|
||||
|
||||
<label class="control-label">Bucket Column</label>
|
||||
<select ng-model="bucket_column" ng-options="value as key for (key, value) in columns" class="form-control"></select>
|
||||
<label class="control-label">Bucket Total Value Column</label>
|
||||
<select ng-model="total_column" ng-options="value as key for (key, value) in columns" class="form-control"></select>
|
||||
<label class="control-label">Day Number Column</label>
|
||||
<select ng-model="value_column" ng-options="value as key for (key, value) in columns" class="form-control"></select>
|
||||
<label class="control-label">Day Value Column</label>
|
||||
<select ng-model="day_column" ng-options="value as key for (key, value) in columns" class="form-control"></select>
|
||||
</div>
|
||||
5
rd_ui/app/views/visualizations/counter.html
Normal file
5
rd_ui/app/views/visualizations/counter.html
Normal file
@@ -0,0 +1,5 @@
|
||||
<counter ng-class="{'positive': targetValue && trendPositive, 'negative': targetValue && !trendPositive}">
|
||||
<value>{{counterValue|number}}</value>
|
||||
<counter-target ng-if="targetValue">({{targetValue|number}})</counter-target>
|
||||
<counter-name>{{visualization.name}}</counter-name>
|
||||
</counter>
|
||||
20
rd_ui/app/views/visualizations/counter_editor.html
Normal file
20
rd_ui/app/views/visualizations/counter_editor.html
Normal file
@@ -0,0 +1,20 @@
|
||||
<div class="form-horizontal">
|
||||
<div class="form-group">
|
||||
<label class="col-lg-6">Row Number</label>
|
||||
<div class="col-lg-6">
|
||||
<input type="number" ng-model="visualization.options.rowNumber" class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="col-lg-6">Counter Column Name</label>
|
||||
<div class="col-lg-6">
|
||||
<select ng-options="name for name in queryResult.columnNames" ng-model="visualization.options.counterColName" class="form-control"></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="col-lg-6">Target Column Name</label>
|
||||
<div class="col-lg-6">
|
||||
<select ng-options="name for name in queryResult.columnNames" ng-model="visualization.options.targetColName" class="form-control"></select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
27
rd_ui/app/views/visualizations/edit_visualization.html
Normal file
27
rd_ui/app/views/visualizations/edit_visualization.html
Normal file
@@ -0,0 +1,27 @@
|
||||
<div>
|
||||
<span ng-click="openEditor=!openEditor" class="details-toggle" ng-class="{open: openEditor}">Edit</span>
|
||||
|
||||
<form ng-if="openEditor" role="form" name="visForm" ng-submit="submit()">
|
||||
<div class="form-group">
|
||||
<label class="control-label">Name</label>
|
||||
<input name="name" type="text" class="form-control" ng-model="visualization.name" placeholder="{{visualization.type | capitalize}}">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="control-label">Visualization Type</label>
|
||||
<select required ng-model="visualization.type" ng-options="value as key for (key, value) in visTypes" class="form-control" ng-change="typeChanged()"></select>
|
||||
</div>
|
||||
|
||||
<visualization-options-editor></visualization-options-editor>
|
||||
|
||||
<div class="form-group" ng-if="editRawOptions">
|
||||
<label class="control-label">Advanced</label>
|
||||
<textarea json-text ng-model="visualization.options" class="form-control" rows="10"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<button type="submit" class="btn btn-primary">Save</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user