mirror of
https://github.com/getredash/redash.git
synced 2025-12-25 01:03:20 -05:00
Compare commits
837 Commits
v0.6.0+b70
...
v0.9.1-rc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b1abaaa52 | ||
|
|
c1da2579a3 | ||
|
|
1b36a62b91 | ||
|
|
ed2e06a787 | ||
|
|
47d3faae92 | ||
|
|
ff49321056 | ||
|
|
ee98b5a5c6 | ||
|
|
245a4b5a3f | ||
|
|
0546528b2c | ||
|
|
d8d925c297 | ||
|
|
fac0af548b | ||
|
|
5deca9bd60 | ||
|
|
b1e0620f85 | ||
|
|
0a35f70a27 | ||
|
|
bd1551fb9d | ||
|
|
f6a8a9975f | ||
|
|
179649d422 | ||
|
|
1c584f65ba | ||
|
|
b62c75ac66 | ||
|
|
f4096c0356 | ||
|
|
419fe389a4 | ||
|
|
031cb63f67 | ||
|
|
a62c5b5b24 | ||
|
|
3befab7244 | ||
|
|
8c006238c5 | ||
|
|
03d897886e | ||
|
|
ebe032070e | ||
|
|
4a29f41ab3 | ||
|
|
566cda359e | ||
|
|
5a1736ad31 | ||
|
|
eed3d50372 | ||
|
|
901cf6f017 | ||
|
|
83458ab25e | ||
|
|
9ab4e0e888 | ||
|
|
89ac67555e | ||
|
|
4d7e58c8d7 | ||
|
|
14c4203593 | ||
|
|
ccec964c24 | ||
|
|
d65e1a799a | ||
|
|
451f216c31 | ||
|
|
270afad6cf | ||
|
|
ccae8bcc69 | ||
|
|
07f96a22af | ||
|
|
3f6cf95307 | ||
|
|
6f2d5090e6 | ||
|
|
9cedb3bb66 | ||
|
|
9751d3584b | ||
|
|
13ced12cc9 | ||
|
|
fdd60b364f | ||
|
|
dde63d1e96 | ||
|
|
174f7c0b1a | ||
|
|
887d7179c4 | ||
|
|
fc84cf39fc | ||
|
|
849c11b5f4 | ||
|
|
66b4fe8e32 | ||
|
|
9d1823426c | ||
|
|
c004274108 | ||
|
|
0b89ee4653 | ||
|
|
caff2e5caa | ||
|
|
aa98f22a04 | ||
|
|
db8915f154 | ||
|
|
ce9a5c05fb | ||
|
|
246725515d | ||
|
|
be4c59e73d | ||
|
|
40e047a47c | ||
|
|
048ef7234c | ||
|
|
bd29bdbb2e | ||
|
|
13252bb0af | ||
|
|
07a709d59a | ||
|
|
55f80695b0 | ||
|
|
991512bc17 | ||
|
|
5e58818043 | ||
|
|
224998c62a | ||
|
|
9a31077a99 | ||
|
|
ab39ed2898 | ||
|
|
cb4fbf81a2 | ||
|
|
7c6b95e71d | ||
|
|
f7b57fa580 | ||
|
|
6e32f5b9f2 | ||
|
|
1a748c2141 | ||
|
|
99ed076c0c | ||
|
|
8a7dd3b46a | ||
|
|
6e28f949fb | ||
|
|
a9ccfb8b42 | ||
|
|
1aba777b61 | ||
|
|
1894df49fa | ||
|
|
200131bb45 | ||
|
|
5e25ba0cf6 | ||
|
|
184d208020 | ||
|
|
610fe2a8a2 | ||
|
|
068ce57b24 | ||
|
|
af61784a28 | ||
|
|
871d8d6b6a | ||
|
|
ea1fac76a3 | ||
|
|
ed380fefaa | ||
|
|
cc9e89bb69 | ||
|
|
e9aeb11685 | ||
|
|
cc2dcb25b6 | ||
|
|
bfb73166c6 | ||
|
|
30adfccd79 | ||
|
|
c3b6de55c0 | ||
|
|
fa2cae1753 | ||
|
|
b337a50fcc | ||
|
|
3d178f9a60 | ||
|
|
a0219bf354 | ||
|
|
ec41077dc1 | ||
|
|
15f9a063ae | ||
|
|
a15085dc93 | ||
|
|
78ae9ac647 | ||
|
|
f31ec7b1dd | ||
|
|
85916efa81 | ||
|
|
31b6e6ff0f | ||
|
|
f20774b6c2 | ||
|
|
dac6cabd1e | ||
|
|
51949230d6 | ||
|
|
81386bcf37 | ||
|
|
67118ee1aa | ||
|
|
e863d83bf4 | ||
|
|
d958817b10 | ||
|
|
450631d6ce | ||
|
|
8b5a0206c2 | ||
|
|
49848a193a | ||
|
|
0f9d5219ef | ||
|
|
3cb14786f5 | ||
|
|
8e432200aa | ||
|
|
30dd030a9d | ||
|
|
fc3fc0e84a | ||
|
|
24b70e66af | ||
|
|
76a1b9fdbe | ||
|
|
e310f9d522 | ||
|
|
86a0e74db8 | ||
|
|
30a70338ba | ||
|
|
b242dbb531 | ||
|
|
ca47b0e6f7 | ||
|
|
7c992c53eb | ||
|
|
4deb150a89 | ||
|
|
63f0a8cc20 | ||
|
|
7e4f5e1e03 | ||
|
|
6f1fed47b3 | ||
|
|
4505437097 | ||
|
|
2ea2df5943 | ||
|
|
135ffd693a | ||
|
|
0f82d4e17b | ||
|
|
32c0d3eb3d | ||
|
|
1bee22a578 | ||
|
|
6bb57508e1 | ||
|
|
b7a43feeca | ||
|
|
2d34bf1c54 | ||
|
|
7e3856b4f5 | ||
|
|
189e105c68 | ||
|
|
378459d64f | ||
|
|
ab72531889 | ||
|
|
51deb8f75d | ||
|
|
68f6e9b5e5 | ||
|
|
fbfa76f4d6 | ||
|
|
28e8e049eb | ||
|
|
47dcead383 | ||
|
|
f1f9597998 | ||
|
|
0da39edf1a | ||
|
|
7845ad5ff7 | ||
|
|
3808b451c6 | ||
|
|
c78789a670 | ||
|
|
2cd08d25a0 | ||
|
|
09ed4d5ede | ||
|
|
1e97a0ce9f | ||
|
|
61cb203ce7 | ||
|
|
58c0c5c099 | ||
|
|
8072b06246 | ||
|
|
65f2c2136b | ||
|
|
8b9a9e9ac4 | ||
|
|
0b389d51aa | ||
|
|
46f3e82571 | ||
|
|
5b64918379 | ||
|
|
7549f32d9a | ||
|
|
6f51776cbb | ||
|
|
ad0afd8f3e | ||
|
|
8863282e58 | ||
|
|
9c1fda488c | ||
|
|
30a494dab0 | ||
|
|
995659ee0d | ||
|
|
ad2642e9e5 | ||
|
|
740b305910 | ||
|
|
ca8cca0a8c | ||
|
|
7c4410ac63 | ||
|
|
91a209ae82 | ||
|
|
60cdb85cc4 | ||
|
|
becb4decf1 | ||
|
|
5f33e7ea18 | ||
|
|
7675de4ec7 | ||
|
|
fe2aa71349 | ||
|
|
b7720f7001 | ||
|
|
3b24f56eba | ||
|
|
06065badd4 | ||
|
|
52b8e98b1a | ||
|
|
5fe9c2fcf0 | ||
|
|
816142aa54 | ||
|
|
f737be272f | ||
|
|
0343fa7980 | ||
|
|
0f9f9a24a0 | ||
|
|
5b9b18639b | ||
|
|
ce46295dd3 | ||
|
|
3781b0758e | ||
|
|
8d20180d40 | ||
|
|
a7b41327c6 | ||
|
|
4d415c0246 | ||
|
|
5331008e78 | ||
|
|
80783feda6 | ||
|
|
2f308c3fa6 | ||
|
|
a63055f7f0 | ||
|
|
ce884ba6d3 | ||
|
|
63765281fe | ||
|
|
47e79003e5 | ||
|
|
541060c62e | ||
|
|
3ba19fa80f | ||
|
|
f3ec0448f5 | ||
|
|
654349a7ae | ||
|
|
2b32de184e | ||
|
|
1fb57edd1f | ||
|
|
f6c65d139a | ||
|
|
4e59472238 | ||
|
|
feabc46da4 | ||
|
|
51a10e5a20 | ||
|
|
5bf370d0f0 | ||
|
|
5beec581d8 | ||
|
|
70080df534 | ||
|
|
0d4c3c329e | ||
|
|
76dfbad971 | ||
|
|
45a85c110f | ||
|
|
f77c0aeb1d | ||
|
|
b23e328f69 | ||
|
|
165d782b98 | ||
|
|
1bdc1bef73 | ||
|
|
e3b41b15d7 | ||
|
|
7a95dec33b | ||
|
|
a3d059041c | ||
|
|
3a6c1599f3 | ||
|
|
f92aa7b15f | ||
|
|
d823506e5b | ||
|
|
fc93de7aa2 | ||
|
|
a0cc25d174 | ||
|
|
df24bc3aae | ||
|
|
60c2cb0a75 | ||
|
|
ad19f2d304 | ||
|
|
3aa59a8152 | ||
|
|
32638aebed | ||
|
|
346ea66c9d | ||
|
|
d14b74b683 | ||
|
|
5d879ce358 | ||
|
|
b4da4359a8 | ||
|
|
7e08518a31 | ||
|
|
bea0e9aad0 | ||
|
|
a87179b68b | ||
|
|
91806eda44 | ||
|
|
d1fe3d63fd | ||
|
|
8408409ce2 | ||
|
|
6bbdd5eb44 | ||
|
|
34ba54397d | ||
|
|
ec79ce74d0 | ||
|
|
f324f1bf6f | ||
|
|
47cfb7d620 | ||
|
|
dab1a21b40 | ||
|
|
aa04a6e4a5 | ||
|
|
e0a43a32ab | ||
|
|
68001ae0f1 | ||
|
|
9d9501b158 | ||
|
|
67aecc0201 | ||
|
|
0bc9fc1ed5 | ||
|
|
b548cb1d8f | ||
|
|
eb5c4dd5f3 | ||
|
|
a07a9b9390 | ||
|
|
56ade4735c | ||
|
|
b8a9f1048a | ||
|
|
3dc62e3c85 | ||
|
|
73b2c5d38e | ||
|
|
5b3bcff4f5 | ||
|
|
b41b21c69e | ||
|
|
172d57e82c | ||
|
|
f507da9df7 | ||
|
|
2e27e43357 | ||
|
|
8a0c287d05 | ||
|
|
664a1806bc | ||
|
|
9a0ccd1bb5 | ||
|
|
076fca0c5a | ||
|
|
59f099418a | ||
|
|
b9a0760d7e | ||
|
|
a0c26c64f0 | ||
|
|
5f47689553 | ||
|
|
a5bc90c816 | ||
|
|
39b8f40ad4 | ||
|
|
070caa6976 | ||
|
|
56b51f68bc | ||
|
|
799ce3e718 | ||
|
|
9b47f0d08a | ||
|
|
4f4dc135f5 | ||
|
|
4eb490a839 | ||
|
|
410c5671f0 | ||
|
|
fad8bd47e8 | ||
|
|
89f5074054 | ||
|
|
5826fbd05f | ||
|
|
ddab1c9493 | ||
|
|
f9d5fe235b | ||
|
|
afe64fe981 | ||
|
|
99efe497ee | ||
|
|
9e183f1500 | ||
|
|
4b17b9869e | ||
|
|
872d58688f | ||
|
|
37272dc2d9 | ||
|
|
1a3df37940 | ||
|
|
ddbf264020 | ||
|
|
e93b71af85 | ||
|
|
13184519c3 | ||
|
|
0f8da884f9 | ||
|
|
21de1d90e3 | ||
|
|
ed9eb691c1 | ||
|
|
d6c229759f | ||
|
|
f0b8dfb449 | ||
|
|
6f335d34b9 | ||
|
|
bed63083a7 | ||
|
|
9886f5b13b | ||
|
|
f0ee7a67d2 | ||
|
|
9c43e1540e | ||
|
|
b0cb2d3f1c | ||
|
|
b525ad0622 | ||
|
|
602b9128a7 | ||
|
|
45d3b18c0c | ||
|
|
b1918743f2 | ||
|
|
716f36ef9c | ||
|
|
62aa21cdc8 | ||
|
|
4e30fc1054 | ||
|
|
5a1d38c572 | ||
|
|
360b0da159 | ||
|
|
cc91981845 | ||
|
|
e19962d4e3 | ||
|
|
99b6f8955e | ||
|
|
cf6ce0599b | ||
|
|
a699c04ee1 | ||
|
|
a8d7547dc7 | ||
|
|
72804e6d80 | ||
|
|
e51db087c5 | ||
|
|
0e9607205b | ||
|
|
9f799f4bfe | ||
|
|
17e0bd4cd2 | ||
|
|
102038b129 | ||
|
|
c01d88cbea | ||
|
|
9d6d88ebff | ||
|
|
3f429ebcb7 | ||
|
|
c854ce3c10 | ||
|
|
ab6cc3f146 | ||
|
|
97d0035f4a | ||
|
|
8108bc7cb1 | ||
|
|
690cb2fccd | ||
|
|
515c45776e | ||
|
|
fc44dba2ef | ||
|
|
5329fe547c | ||
|
|
d6bb6d33a3 | ||
|
|
9832b7f72a | ||
|
|
2a6ed3ca52 | ||
|
|
2e78ef0128 | ||
|
|
d2d52d44f7 | ||
|
|
987f4bd356 | ||
|
|
0c8c196d65 | ||
|
|
9d703b44de | ||
|
|
fb00350c58 | ||
|
|
6cccd30553 | ||
|
|
0bbcb69197 | ||
|
|
b0eaffdf6c | ||
|
|
407a649d17 | ||
|
|
73bd83a527 | ||
|
|
72e48a191b | ||
|
|
11682b3779 | ||
|
|
a15d7964fa | ||
|
|
2feb8b81f5 | ||
|
|
6286024350 | ||
|
|
0b5dce0ebf | ||
|
|
32311c55e6 | ||
|
|
2ac795d6f7 | ||
|
|
d50af7dec9 | ||
|
|
20159a1c2a | ||
|
|
06400ed840 | ||
|
|
0ddc6cf135 | ||
|
|
46a008346f | ||
|
|
21c413f699 | ||
|
|
e7222944a5 | ||
|
|
f49839eadf | ||
|
|
aa1b72908b | ||
|
|
5dd457e5f1 | ||
|
|
a471134e07 | ||
|
|
8a8f91ee8f | ||
|
|
59aa218b24 | ||
|
|
5fd8dbe523 | ||
|
|
a08f3c7cd0 | ||
|
|
824d053ddd | ||
|
|
b6e61deb24 | ||
|
|
4f40b28120 | ||
|
|
5d1c75df1c | ||
|
|
28ccaedfff | ||
|
|
1ee05e12fd | ||
|
|
6f91849419 | ||
|
|
65cc67d1dd | ||
|
|
a8f6d9e45b | ||
|
|
2c39a2faae | ||
|
|
1052528a5f | ||
|
|
92cd2f1367 | ||
|
|
990717a43d | ||
|
|
a2608d6a44 | ||
|
|
dedae03c8c | ||
|
|
61f2be02b7 | ||
|
|
9eca43801a | ||
|
|
bcaefda600 | ||
|
|
42b0430866 | ||
|
|
445dbb5ade | ||
|
|
40ee0d8a6e | ||
|
|
a5b738a035 | ||
|
|
e893ab4519 | ||
|
|
8b569379bc | ||
|
|
bff3e7c3b2 | ||
|
|
3fbd0d9579 | ||
|
|
00f4ec16f8 | ||
|
|
6f24b31858 | ||
|
|
7a8844180b | ||
|
|
aefaf204a3 | ||
|
|
1527ea36b1 | ||
|
|
a71b83d98a | ||
|
|
7add6287dc | ||
|
|
d37b5ed075 | ||
|
|
23b8b77feb | ||
|
|
46f1478e0d | ||
|
|
ec46312bf6 | ||
|
|
7c308bee09 | ||
|
|
5f656f3868 | ||
|
|
4e27331d56 | ||
|
|
8f28c52b8d | ||
|
|
47e6960b83 | ||
|
|
0990d93b03 | ||
|
|
bf88d8b578 | ||
|
|
384e756817 | ||
|
|
d2c46c99eb | ||
|
|
9c2858191f | ||
|
|
0473de7392 | ||
|
|
faece4f2c4 | ||
|
|
c9e74104b1 | ||
|
|
d100c915f4 | ||
|
|
ef3636145c | ||
|
|
6bd7dc9237 | ||
|
|
6210d6ab80 | ||
|
|
864a12a3be | ||
|
|
f48c47712d | ||
|
|
2c90fb3fa9 | ||
|
|
176fd16e95 | ||
|
|
75d3a63070 | ||
|
|
8c4a5a644e | ||
|
|
5b024a3518 | ||
|
|
d474267934 | ||
|
|
9429314b6e | ||
|
|
7cd132b47d | ||
|
|
89661990e7 | ||
|
|
01564d7e10 | ||
|
|
98307aec0d | ||
|
|
5de3de12f0 | ||
|
|
dea64734d6 | ||
|
|
98857ea64c | ||
|
|
3181f28509 | ||
|
|
37745ad1c0 | ||
|
|
5fe5c94b3d | ||
|
|
59cbafa724 | ||
|
|
1d99da5a32 | ||
|
|
8dfa1ca7bd | ||
|
|
1fb6860ee2 | ||
|
|
99c50c1f64 | ||
|
|
b1576b5a91 | ||
|
|
6f2ee2c0bb | ||
|
|
eec5e3290b | ||
|
|
aaac5928c4 | ||
|
|
b97b35d9b5 | ||
|
|
6955514ec3 | ||
|
|
c8d5267bc7 | ||
|
|
993a861c78 | ||
|
|
a11e100050 | ||
|
|
470ec4924c | ||
|
|
cdb6aaac6e | ||
|
|
580d33a6f8 | ||
|
|
8686694be9 | ||
|
|
795a9fe011 | ||
|
|
4b08a3a5f2 | ||
|
|
d9e8a81655 | ||
|
|
7000547419 | ||
|
|
e0100543cd | ||
|
|
7ea640927f | ||
|
|
db26cafc41 | ||
|
|
100b9e7c71 | ||
|
|
d3391db8f0 | ||
|
|
1ad01d8394 | ||
|
|
3ef3f2c01b | ||
|
|
371422a9ae | ||
|
|
f4af650292 | ||
|
|
5f38e87f01 | ||
|
|
b98e4a27ce | ||
|
|
9ff8db31d2 | ||
|
|
446148d07f | ||
|
|
2d6ca50568 | ||
|
|
650ccac501 | ||
|
|
ab507f0fd5 | ||
|
|
7187b5ffee | ||
|
|
5e73da1df4 | ||
|
|
244d25b12c | ||
|
|
2dcf676cf2 | ||
|
|
e07af676a5 | ||
|
|
3dea6302de | ||
|
|
b1ceb60360 | ||
|
|
1ef94b77e9 | ||
|
|
292d31e490 | ||
|
|
6f0ac1e730 | ||
|
|
9f82e5850d | ||
|
|
4a18fa07ec | ||
|
|
05d1886467 | ||
|
|
6e45706825 | ||
|
|
464402a233 | ||
|
|
3a56b9ded7 | ||
|
|
142295671b | ||
|
|
0e46a24112 | ||
|
|
a3cb698be0 | ||
|
|
08730ad113 | ||
|
|
d155f166d7 | ||
|
|
ca95e9252f | ||
|
|
d078e80e79 | ||
|
|
8ad1d2672c | ||
|
|
735130efc9 | ||
|
|
7e6b7398a4 | ||
|
|
edf8f5b1fd | ||
|
|
08c09d896a | ||
|
|
58403634cf | ||
|
|
2eb171e40d | ||
|
|
3753f58980 | ||
|
|
fe1cc78ab3 | ||
|
|
c140668648 | ||
|
|
41ca1321cf | ||
|
|
d88340158a | ||
|
|
52f335edd5 | ||
|
|
22200ec7b2 | ||
|
|
e458ed03c8 | ||
|
|
e9f1e3a189 | ||
|
|
d202570b0d | ||
|
|
9b6edde5c8 | ||
|
|
975c92d40d | ||
|
|
27639f83c7 | ||
|
|
c08e6791df | ||
|
|
5c7158b6ae | ||
|
|
b886067a9f | ||
|
|
2421de8819 | ||
|
|
9e87e42400 | ||
|
|
8c750826e3 | ||
|
|
b14b6d1773 | ||
|
|
76cb73f4ce | ||
|
|
8854a45598 | ||
|
|
228b8c7614 | ||
|
|
5de79213ae | ||
|
|
c7d30c8b87 | ||
|
|
076710f0c6 | ||
|
|
a9172dac00 | ||
|
|
accca51f39 | ||
|
|
5f5774d01b | ||
|
|
00e99d858c | ||
|
|
da56dc883f | ||
|
|
02582cab65 | ||
|
|
bff4d31ada | ||
|
|
83554207e1 | ||
|
|
1c0c3e0b93 | ||
|
|
5feb563dc9 | ||
|
|
07b88d0b53 | ||
|
|
21f33462d5 | ||
|
|
6a9d95f1ac | ||
|
|
36b80fc4ef | ||
|
|
d89dd2c9af | ||
|
|
658af526c7 | ||
|
|
3d859ec5f3 | ||
|
|
fdff799d23 | ||
|
|
5fc0b88b23 | ||
|
|
63de247478 | ||
|
|
5d3caac1b5 | ||
|
|
e4b9d23dfe | ||
|
|
890f59a4c9 | ||
|
|
d4a18ba611 | ||
|
|
c4502b2925 | ||
|
|
1d5efdd93f | ||
|
|
2b95da102e | ||
|
|
d512cd0c1d | ||
|
|
3dc9c84a98 | ||
|
|
4a33b987b8 | ||
|
|
f7041977d5 | ||
|
|
83bc38579e | ||
|
|
4b8a94e795 | ||
|
|
406010a7a6 | ||
|
|
4f11f28efa | ||
|
|
c919602b20 | ||
|
|
7702b05635 | ||
|
|
5fc7c499a3 | ||
|
|
628240906e | ||
|
|
41b9b21a20 | ||
|
|
dbd3f754ba | ||
|
|
4ef3c27fe6 | ||
|
|
58a005c71b | ||
|
|
9d7ff31178 | ||
|
|
93d6b01fbf | ||
|
|
7d57f9d0f1 | ||
|
|
e80f470255 | ||
|
|
5636cec0eb | ||
|
|
912bbc1a4a | ||
|
|
d3bb58167e | ||
|
|
2911fa8af7 | ||
|
|
4503c6af66 | ||
|
|
7fc2d5ee0b | ||
|
|
3c9c1466a3 | ||
|
|
4a7c066bf0 | ||
|
|
b850da52a2 | ||
|
|
1a3657572e | ||
|
|
666e3281e4 | ||
|
|
66084b1a3b | ||
|
|
421470666a | ||
|
|
f8e2bc9eca | ||
|
|
079fbf33f4 | ||
|
|
c195362710 | ||
|
|
b671dd0431 | ||
|
|
7793f3b257 | ||
|
|
e09aa6f81a | ||
|
|
780e0c0418 | ||
|
|
43edb009d6 | ||
|
|
81978c5049 | ||
|
|
239813e195 | ||
|
|
28dd571a03 | ||
|
|
808126cf91 | ||
|
|
69a8295f4c | ||
|
|
a692e3f664 | ||
|
|
6860dde1f7 | ||
|
|
e183affdd0 | ||
|
|
6338be3811 | ||
|
|
3ee6371250 | ||
|
|
4f38d42182 | ||
|
|
39db74ff20 | ||
|
|
05c2c21a85 | ||
|
|
00edc29e50 | ||
|
|
3771af0a8c | ||
|
|
c32c2d43f7 | ||
|
|
4e2e3f9077 | ||
|
|
2a27422df9 | ||
|
|
f9e0ce8e9c | ||
|
|
a1d49f13d3 | ||
|
|
26aa199f9c | ||
|
|
4c77f3f914 | ||
|
|
d6be792595 | ||
|
|
59c1ea7f16 | ||
|
|
4d24005eff | ||
|
|
2dab35b614 | ||
|
|
0b61b88f5f | ||
|
|
e5cb58207c | ||
|
|
fc17d1af81 | ||
|
|
e6650e1e2d | ||
|
|
3aa1cd0133 | ||
|
|
e04833c327 | ||
|
|
b743cceb60 | ||
|
|
a0e134d3b5 | ||
|
|
d7fb2d7458 | ||
|
|
b913ce6022 | ||
|
|
1eb7945d16 | ||
|
|
37d0026ee4 | ||
|
|
9cdc2cb2f7 | ||
|
|
a9bff9063e | ||
|
|
380126ee44 | ||
|
|
d8377375b8 | ||
|
|
98ff701f9a | ||
|
|
f5ea3e97d3 | ||
|
|
719e96dd2f | ||
|
|
6c6c0256ba | ||
|
|
723df51cdd | ||
|
|
a0f4e263b2 | ||
|
|
4706bf8060 | ||
|
|
f96a9f659a | ||
|
|
63c273f896 | ||
|
|
622ac6d781 | ||
|
|
8dc564a8bc | ||
|
|
3ae5baef22 | ||
|
|
8d819068b5 | ||
|
|
585e056265 | ||
|
|
1914ed7c7c | ||
|
|
bd216e93e7 | ||
|
|
5e351de896 | ||
|
|
de0e534c77 | ||
|
|
5fa1f9440d | ||
|
|
b3ddc5f8b9 | ||
|
|
8cde5f9673 | ||
|
|
1bb53ca497 | ||
|
|
0a3cd9267f | ||
|
|
075d843354 | ||
|
|
b14e5e8c0e | ||
|
|
c9da4be422 | ||
|
|
276ee7c27a | ||
|
|
334040532a | ||
|
|
335a3a98b5 | ||
|
|
b17080a7f5 | ||
|
|
8441c12b01 | ||
|
|
3b4af1b6fa | ||
|
|
c3deb8e2fa | ||
|
|
a60b1686da | ||
|
|
b56e87ceb2 | ||
|
|
fc89bcdaf3 | ||
|
|
15ec8321bb | ||
|
|
e6ba62485c | ||
|
|
9077b01fb9 | ||
|
|
f45281be96 | ||
|
|
a1c8ef9037 | ||
|
|
f46e8af23f | ||
|
|
30a89bfd2c | ||
|
|
6312f8738d | ||
|
|
9e3d5c10c5 | ||
|
|
59b87ec4fd | ||
|
|
27ecf5f25c | ||
|
|
105971c4c8 | ||
|
|
690f8323c3 | ||
|
|
20eb110ce3 | ||
|
|
571c9d0aee | ||
|
|
0ee7292f16 | ||
|
|
8c28392dfd | ||
|
|
671f1f4478 | ||
|
|
557d3748be | ||
|
|
f00d080ed2 | ||
|
|
4e76c1305f | ||
|
|
36ef388e92 | ||
|
|
2e1ee7f76c | ||
|
|
fc1e38772d | ||
|
|
0e631a5121 | ||
|
|
d74175efca | ||
|
|
bf5fe7d2c7 | ||
|
|
0f022aba92 | ||
|
|
0b6e55e55a | ||
|
|
e1c409366c | ||
|
|
3b942118e9 | ||
|
|
7f1543db8f | ||
|
|
74a5121be2 | ||
|
|
26fe136a1a | ||
|
|
83fb189b05 | ||
|
|
5e8d0d36c0 | ||
|
|
4ae4cffa04 | ||
|
|
bc433e88fe | ||
|
|
513ef501a4 | ||
|
|
f2bdcbedfb | ||
|
|
fd056edb2a | ||
|
|
0f0acfdd12 | ||
|
|
1e3b507b2b | ||
|
|
84d95272f3 | ||
|
|
3b08e9e214 | ||
|
|
f4be83b06f | ||
|
|
4918d0430c | ||
|
|
e25b86b10d | ||
|
|
d3d305a843 | ||
|
|
825b93bfe9 | ||
|
|
8c98282200 | ||
|
|
768ac9eb04 | ||
|
|
71011d2fca | ||
|
|
9683a8ed82 | ||
|
|
10a6ac9313 | ||
|
|
dba325e9a2 | ||
|
|
fcd9ab533c | ||
|
|
68e3e8e1c5 | ||
|
|
7f8b738b9e | ||
|
|
8a35dcedfa | ||
|
|
ef763b7157 | ||
|
|
498e1d4474 | ||
|
|
73de936c75 | ||
|
|
e32b709a41 | ||
|
|
60652f63c4 | ||
|
|
d0d4101f90 | ||
|
|
646875794f | ||
|
|
cdad4be0d5 | ||
|
|
8f4285be62 | ||
|
|
acfa55e2d0 | ||
|
|
0b7cd07db0 | ||
|
|
6297ffd523 | ||
|
|
368f4fdbef | ||
|
|
f52044a209 | ||
|
|
9fb33cf746 | ||
|
|
e3c5da5bc5 | ||
|
|
e675690cc6 | ||
|
|
edc1622cf5 | ||
|
|
5ab3d4a40d | ||
|
|
cb29d87b63 | ||
|
|
6ff6bdad9f | ||
|
|
e3cc3ef9a4 | ||
|
|
1fe4f291f2 | ||
|
|
a54119f4a2 | ||
|
|
c5b7fe5321 | ||
|
|
d487ec9153 | ||
|
|
fa19b1ddc8 | ||
|
|
267c32b390 | ||
|
|
aeff3f1494 | ||
|
|
e80e52f6c9 | ||
|
|
fe41a70602 | ||
|
|
976d9abe2d | ||
|
|
041bc1100a | ||
|
|
5d095ff6ab | ||
|
|
ef01b61b29 | ||
|
|
faad6b656b | ||
|
|
0bc775584b | ||
|
|
f2d96d61a1 | ||
|
|
09bf2dd608 | ||
|
|
ad1b9b06cf | ||
|
|
a4bceae60b | ||
|
|
9385449feb | ||
|
|
562e1bb8c9 | ||
|
|
082b718303 | ||
|
|
c0872899e9 | ||
|
|
086bbf129d | ||
|
|
4b7561e538 | ||
|
|
407c5a839b | ||
|
|
b8aefd26b8 | ||
|
|
85a762bcd2 | ||
|
|
4f1b3d5beb | ||
|
|
9218a7c437 | ||
|
|
71a3f066a5 | ||
|
|
89436d779c | ||
|
|
3631e938da | ||
|
|
c0a9db68f0 | ||
|
|
bec9c9e14e | ||
|
|
47bbc25277 | ||
|
|
f02c2588d2 | ||
|
|
7db5449dad | ||
|
|
7f6c7f0634 | ||
|
|
73955c74f7 | ||
|
|
7de85da8ef | ||
|
|
0aab35252a | ||
|
|
141dbc9e70 | ||
|
|
2e513c347c | ||
|
|
335c136ec2 | ||
|
|
df1170eb9b | ||
|
|
69bcaddbe0 | ||
|
|
67958cc27b | ||
|
|
6c716f23d9 | ||
|
|
bea11b0ac2 | ||
|
|
4927386299 |
4
.dockerignore
Normal file
4
.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
||||
rd_ui/.tmp/
|
||||
rd_ui/node_modules/
|
||||
.git/
|
||||
.vagrant/
|
||||
13
.env.example
13
.env.example
@@ -1,9 +1,6 @@
|
||||
REDASH_CONNECTION_ADAPTER=pg
|
||||
REDASH_CONNECTION_STRING="dbname=data"
|
||||
REDASH_STATIC_ASSETS_PATH=../rd_ui/app/
|
||||
REDASH_STATIC_ASSETS_PATH="../rd_ui/app/"
|
||||
REDASH_LOG_LEVEL="INFO"
|
||||
REDASH_REDIS_URL=redis://localhost:6379/1
|
||||
REDASH_DATABASE_URL="postgresql://redash"
|
||||
REDASH_COOKIE_SECRET=veryverysecret
|
||||
REDASH_GOOGLE_APPS_DOMAIN=
|
||||
REDASH_ADMINS=
|
||||
REDASH_WORKERS_COUNT=2
|
||||
REDASH_COOKIE_SECRET=
|
||||
REDASH_DATABASE_URL='postgresql://rd'
|
||||
REDASH_LOG_LEVEL = "INFO"
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -8,6 +8,7 @@ celerybeat-schedule*
|
||||
.#*
|
||||
\#*#
|
||||
*~
|
||||
_build
|
||||
|
||||
# Vagrant related
|
||||
.vagrant
|
||||
@@ -18,3 +19,6 @@ redash/dump.rdb
|
||||
venv
|
||||
|
||||
dump.rdb
|
||||
|
||||
# Docker related
|
||||
docker-compose.yml
|
||||
|
||||
52
Dockerfile
Normal file
52
Dockerfile
Normal file
@@ -0,0 +1,52 @@
|
||||
FROM ubuntu:trusty
|
||||
MAINTAINER Di Wu <diwu@yelp.com>
|
||||
|
||||
# Ubuntu packages
|
||||
RUN apt-get update && \
|
||||
apt-get install -y python-pip python-dev curl build-essential pwgen libffi-dev sudo git-core wget \
|
||||
# Postgres client
|
||||
libpq-dev \
|
||||
# Additional packages required for data sources:
|
||||
libssl-dev libmysqlclient-dev && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Users creation
|
||||
RUN useradd --system --comment " " --create-home redash
|
||||
|
||||
# Pip requirements for all data source types
|
||||
RUN pip install -U setuptools && \
|
||||
pip install supervisor==3.1.2
|
||||
|
||||
COPY . /opt/redash/current
|
||||
RUN chown -R redash /opt/redash/current
|
||||
|
||||
# Setting working directory
|
||||
WORKDIR /opt/redash/current
|
||||
|
||||
# Install project specific dependencies
|
||||
RUN pip install -r requirements_all_ds.txt && \
|
||||
pip install -r requirements.txt
|
||||
|
||||
RUN curl https://deb.nodesource.com/setup_4.x | bash - && \
|
||||
apt-get install -y nodejs && \
|
||||
sudo -u redash -H make deps && \
|
||||
rm -rf rd_ui/node_modules /home/redash/.npm /home/redash/.cache && \
|
||||
apt-get purge -y nodejs && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Setup supervisord
|
||||
RUN mkdir -p /opt/redash/supervisord && \
|
||||
mkdir -p /opt/redash/logs && \
|
||||
cp /opt/redash/current/setup/docker/supervisord/supervisord.conf /opt/redash/supervisord/supervisord.conf
|
||||
|
||||
# Fix permissions
|
||||
RUN chown -R redash /opt/redash
|
||||
|
||||
# Expose ports
|
||||
EXPOSE 5000
|
||||
EXPOSE 9001
|
||||
|
||||
# Startup script
|
||||
CMD ["supervisord", "-c", "/opt/redash/supervisord/supervisord.conf"]
|
||||
16
Makefile
16
Makefile
@@ -1,22 +1,22 @@
|
||||
NAME=redash
|
||||
VERSION=`python ./manage.py version`
|
||||
FULL_VERSION=$(VERSION)+b$(CIRCLE_BUILD_NUM)
|
||||
BASE_VERSION=$(shell python ./manage.py version | cut -d + -f 1)
|
||||
# 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 -g bower grunt-cli
|
||||
cd rd_ui && bower install
|
||||
cd rd_ui && grunt build
|
||||
if [ -d "./rd_ui/app" ]; then cd rd_ui && npm install; fi
|
||||
if [ -d "./rd_ui/app" ]; then cd rd_ui && npm run bower install; fi
|
||||
if [ -d "./rd_ui/app" ]; then cd rd_ui && npm run build; fi
|
||||
|
||||
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" *
|
||||
tar -zcv -f $(FILENAME) --exclude="optipng*" --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 $(VERSION) $(FILENAME)
|
||||
python bin/release_manager.py $(CIRCLE_SHA1) $(BASE_VERSION) $(FILENAME)
|
||||
|
||||
test:
|
||||
nosetests --with-coverage --cover-package=redash tests/*.py
|
||||
cd rd_ui && grunt test
|
||||
nosetests --with-coverage --cover-package=redash tests/
|
||||
#cd rd_ui && grunt test
|
||||
|
||||
30
README.md
30
README.md
@@ -1,9 +1,12 @@
|
||||
<p align="center">
|
||||
<img title="re:dash" src='https://raw.githubusercontent.com/EverythingMe/redash/screenshots/redash_logo.png' />
|
||||
More details about the future of re:dash : http://bit.ly/journey-first-step
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<img title="re:dash" src='http://redash.io/static/old_img/redash_logo.png' width="200px"/>
|
||||
</p>
|
||||
<p align="center">
|
||||
<img title="Build Status" src='https://circleci.com/gh/EverythingMe/redash.png?circle-token=8a695aa5ec2cbfa89b48c275aea298318016f040'/>
|
||||
<img title="Build Status" src='https://circleci.com/gh/getredash/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.
|
||||
@@ -11,7 +14,8 @@
|
||||
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.
|
||||
Today **_re:dash_** has support for querying multiple databases, including: Redshift, Google BigQuery, PostgreSQL, MySQL, Graphite,
|
||||
Presto, Google Spreadsheets, Cloudera Impala, Hive and custom scripts.
|
||||
|
||||
**_re:dash_** consists of two parts:
|
||||
|
||||
@@ -22,31 +26,27 @@ Today **_re:dash_** has support for querying multiple databases, including: Reds
|
||||
|
||||
## Demo
|
||||
|
||||

|
||||

|
||||
|
||||
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).
|
||||
* [Setting up re:dash instance](http://redash.io/deployment/setup.html) (includes links to ready made AWS/GCE images).
|
||||
* [Documentation](http://docs.redash.io).
|
||||
|
||||
|
||||
## 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.
|
||||
* Find us [on gitter](https://gitter.im/EverythingMe/redash#) (chat).
|
||||
* Contact Arik, the maintainer directly: arik@everything.me.
|
||||
|
||||
## Roadmap
|
||||
|
||||
TBD.
|
||||
* Find us [on gitter](https://gitter.im/getredash/redash#) (chat).
|
||||
* Contact Arik, the maintainer directly: arik@redash.io.
|
||||
|
||||
## Reporting Bugs and Contributing Code
|
||||
|
||||
* Want to report a bug or request a feature? Please open [an issue](https://github.com/everythingme/redash/issues/new).
|
||||
* Want to report a bug or request a feature? Please open [an issue](https://github.com/getredash/redash/issues/new).
|
||||
* Want to help us build **_re:dash_**? Fork the project and make a pull request. We need all the help we can get!
|
||||
|
||||
## License
|
||||
|
||||
See [LICENSE](https://github.com/EverythingMe/redash/blob/master/LICENSE) file.
|
||||
See [LICENSE](https://github.com/getredash/redash/blob/master/LICENSE) file.
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
#!/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)
|
||||
|
||||
|
||||
147
bin/release_manager.py
Normal file
147
bin/release_manager.py
Normal file
@@ -0,0 +1,147 @@
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import requests
|
||||
|
||||
github_token = os.environ['GITHUB_TOKEN']
|
||||
auth = (github_token, 'x-oauth-basic')
|
||||
repo = 'getredash/redash'
|
||||
|
||||
def _github_request(method, path, params=None, headers={}):
|
||||
if not path.startswith('https://api.github.com'):
|
||||
url = "https://api.github.com/{}".format(path)
|
||||
else:
|
||||
url = path
|
||||
|
||||
if params is not None:
|
||||
params = json.dumps(params)
|
||||
|
||||
response = requests.request(method, url, data=params, auth=auth)
|
||||
return response
|
||||
|
||||
def exception_from_error(message, response):
|
||||
return Exception("({}) {}: {}".format(response.status_code, message, response.json().get('message', '?')))
|
||||
|
||||
def rc_tag_name(version):
|
||||
return "v{}-rc".format(version)
|
||||
|
||||
def get_rc_release(version):
|
||||
tag = rc_tag_name(version)
|
||||
response = _github_request('get', 'repos/{}/releases/tags/{}'.format(repo, tag))
|
||||
|
||||
if response.status_code == 404:
|
||||
return None
|
||||
elif response.status_code == 200:
|
||||
return response.json()
|
||||
|
||||
raise exception_from_error("Unknown error while looking RC release: ", response)
|
||||
|
||||
def create_release(version, commit_sha):
|
||||
tag = rc_tag_name(version)
|
||||
|
||||
params = {
|
||||
'tag_name': tag,
|
||||
'name': "{} - RC".format(version),
|
||||
'target_commitish': commit_sha,
|
||||
'prerelease': True
|
||||
}
|
||||
|
||||
response = _github_request('post', 'repos/{}/releases'.format(repo), params)
|
||||
|
||||
if response.status_code != 201:
|
||||
raise exception_from_error("Failed creating new release", response)
|
||||
|
||||
return response.json()
|
||||
|
||||
def upload_asset(release, filepath):
|
||||
upload_url = release['upload_url'].replace('{?name,label}', '')
|
||||
filename = filepath.split('/')[-1]
|
||||
|
||||
with open(filepath) as file_content:
|
||||
headers = {'Content-Type': 'application/gzip'}
|
||||
response = requests.post(upload_url, file_content, params={'name': filename}, headers=headers, auth=auth, verify=False)
|
||||
|
||||
if response.status_code != 201: # not 200/201/...
|
||||
raise exception_from_error('Failed uploading asset', response)
|
||||
|
||||
return response
|
||||
|
||||
def remove_previous_builds(release):
|
||||
for asset in release['assets']:
|
||||
response = _github_request('delete', asset['url'])
|
||||
if response.status_code != 204:
|
||||
raise exception_from_error("Failed deleting asset", response)
|
||||
|
||||
def get_changelog(commit_sha):
|
||||
latest_release = _github_request('get', 'repos/{}/releases/latest'.format(repo))
|
||||
if latest_release.status_code != 200:
|
||||
raise exception_from_error('Failed getting latest release', latest_release)
|
||||
|
||||
latest_release = latest_release.json()
|
||||
previous_sha = latest_release['target_commitish']
|
||||
|
||||
args = ['git', '--no-pager', 'log', '--merges', '--grep', 'Merge pull request', '--pretty=format:"%h|%s|%b|%p"', '{}...{}'.format(previous_sha, commit_sha)]
|
||||
log = subprocess.check_output(args)
|
||||
changes = ["Changes since {}:".format(latest_release['name'])]
|
||||
|
||||
for line in log.split('\n'):
|
||||
try:
|
||||
sha, subject, body, parents = line[1:-1].split('|')
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
try:
|
||||
pull_request = re.match("Merge pull request #(\d+)", subject).groups()[0]
|
||||
pull_request = " #{}".format(pull_request)
|
||||
except Exception, ex:
|
||||
pull_request = ""
|
||||
|
||||
author = subprocess.check_output(['git', 'log', '-1', '--pretty=format:"%an"', parents.split(' ')[-1]])[1:-1]
|
||||
|
||||
changes.append("{}{}: {} ({})".format(sha, pull_request, body.strip(), author))
|
||||
|
||||
return "\n".join(changes)
|
||||
|
||||
def update_release_commit_sha(release, commit_sha):
|
||||
params = {
|
||||
'target_commitish': commit_sha,
|
||||
}
|
||||
|
||||
response = _github_request('patch', 'repos/{}/releases/{}'.format(repo, release['id']), params)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise exception_from_error("Failed updating commit sha for existing release", response)
|
||||
|
||||
return response.json()
|
||||
|
||||
def update_release(version, build_filepath, commit_sha):
|
||||
try:
|
||||
release = get_rc_release(version)
|
||||
if release:
|
||||
release = update_release_commit_sha(release, commit_sha)
|
||||
else:
|
||||
release = create_release(version, commit_sha)
|
||||
|
||||
print "Using release id: {}".format(release['id'])
|
||||
|
||||
remove_previous_builds(release)
|
||||
response = upload_asset(release, build_filepath)
|
||||
|
||||
changelog = get_changelog(commit_sha)
|
||||
|
||||
response = _github_request('patch', release['url'], {'body': changelog})
|
||||
if response.status_code != 200:
|
||||
raise exception_from_error("Failed updating release description", response)
|
||||
|
||||
except Exception, ex:
|
||||
print ex
|
||||
|
||||
if __name__ == '__main__':
|
||||
commit_sha = sys.argv[1]
|
||||
version = sys.argv[2]
|
||||
filepath = sys.argv[3]
|
||||
|
||||
# TODO: make sure running from git directory & remote = repo
|
||||
update_release(version, filepath, commit_sha)
|
||||
@@ -1,63 +0,0 @@
|
||||
"""
|
||||
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."
|
||||
@@ -1,46 +0,0 @@
|
||||
#!python
|
||||
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']
|
||||
|
||||
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)
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
response = requests.post('https://api.github.com/repos/everythingme/redash/releases',
|
||||
data=params,
|
||||
auth=auth)
|
||||
|
||||
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)
|
||||
|
||||
23
circle.yml
23
circle.yml
@@ -1,28 +1,37 @@
|
||||
machine:
|
||||
services:
|
||||
- docker
|
||||
node:
|
||||
version:
|
||||
0.10.24
|
||||
0.12.4
|
||||
python:
|
||||
version:
|
||||
2.7.3
|
||||
dependencies:
|
||||
pre:
|
||||
- make deps
|
||||
- pip install -r dev_requirements.txt
|
||||
- pip install -r requirements_dev.txt
|
||||
- pip install -r requirements.txt
|
||||
cache_directories:
|
||||
- rd_ui/node_modules/
|
||||
- rd_ui/app/bower_components/
|
||||
test:
|
||||
override:
|
||||
- make test
|
||||
post:
|
||||
- make pack
|
||||
- nosetests --with-xunit --xunit-file=$CIRCLE_TEST_REPORTS/junit.xml --with-coverage --cover-package=redash tests/
|
||||
deployment:
|
||||
github:
|
||||
github_and_docker:
|
||||
branch: master
|
||||
commands:
|
||||
- make deps
|
||||
- make pack
|
||||
- make upload
|
||||
- echo "rd_ui/app" >> .dockerignore
|
||||
- docker build -t redash/redash:$(./manage.py version | sed -e "s/\+/./") .
|
||||
- docker login -e $DOCKER_EMAIL -u $DOCKER_USER -p $DOCKER_PASS
|
||||
- docker push redash/redash:$(./manage.py version | sed -e "s/\+/./")
|
||||
notify:
|
||||
webhooks:
|
||||
- url: https://webhooks.gitter.im/e/895d09c3165a0913ac2f
|
||||
general:
|
||||
branches:
|
||||
ignore:
|
||||
- gh-pages
|
||||
|
||||
28
docker-compose-example.yml
Normal file
28
docker-compose-example.yml
Normal file
@@ -0,0 +1,28 @@
|
||||
redash:
|
||||
image: redash
|
||||
ports:
|
||||
- "5000:5000"
|
||||
links:
|
||||
- redis
|
||||
- postgres
|
||||
environment:
|
||||
REDASH_STATIC_ASSETS_PATH:"../rd_ui/app/"
|
||||
REDASH_LOG_LEVEL:"INFO"
|
||||
REDASH_REDIS_URL:redis://localhost:6379/0
|
||||
REDASH_DATABASE_URL:"postgresql://redash"
|
||||
REDASH_COOKIE_SECRET:veryverysecret
|
||||
REDASH_GOOGLE_APPS_DOMAIN:
|
||||
redis:
|
||||
image: redis:2.8
|
||||
postgres:
|
||||
image: postgres:9.3
|
||||
volumes:
|
||||
- /opt/postgres-data:/var/lib/postgresql/data
|
||||
redash-nginx:
|
||||
image: redash-nginx:1.0
|
||||
ports:
|
||||
- "80:80"
|
||||
volumes:
|
||||
- "../redash-nginx/nginx.conf:/etc/nginx/nginx.conf"
|
||||
links:
|
||||
- redash
|
||||
192
docs/Makefile
Normal file
192
docs/Makefile
Normal file
@@ -0,0 +1,192 @@
|
||||
# Makefile for Sphinx documentation
|
||||
#
|
||||
|
||||
# You can set these variables from the command line.
|
||||
SPHINXOPTS =
|
||||
SPHINXBUILD = sphinx-build
|
||||
PAPER =
|
||||
BUILDDIR = _build
|
||||
|
||||
# User-friendly check for sphinx-build
|
||||
ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1)
|
||||
$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/)
|
||||
endif
|
||||
|
||||
# Internal variables.
|
||||
PAPEROPT_a4 = -D latex_paper_size=a4
|
||||
PAPEROPT_letter = -D latex_paper_size=letter
|
||||
ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
|
||||
# the i18n builder cannot share the environment and doctrees with the others
|
||||
I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
|
||||
|
||||
.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest coverage gettext
|
||||
|
||||
help:
|
||||
@echo "Please use \`make <target>' where <target> is one of"
|
||||
@echo " html to make standalone HTML files"
|
||||
@echo " dirhtml to make HTML files named index.html in directories"
|
||||
@echo " singlehtml to make a single large HTML file"
|
||||
@echo " pickle to make pickle files"
|
||||
@echo " json to make JSON files"
|
||||
@echo " htmlhelp to make HTML files and a HTML help project"
|
||||
@echo " qthelp to make HTML files and a qthelp project"
|
||||
@echo " applehelp to make an Apple Help Book"
|
||||
@echo " devhelp to make HTML files and a Devhelp project"
|
||||
@echo " epub to make an epub"
|
||||
@echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter"
|
||||
@echo " latexpdf to make LaTeX files and run them through pdflatex"
|
||||
@echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx"
|
||||
@echo " text to make text files"
|
||||
@echo " man to make manual pages"
|
||||
@echo " texinfo to make Texinfo files"
|
||||
@echo " info to make Texinfo files and run them through makeinfo"
|
||||
@echo " gettext to make PO message catalogs"
|
||||
@echo " changes to make an overview of all changed/added/deprecated items"
|
||||
@echo " xml to make Docutils-native XML files"
|
||||
@echo " pseudoxml to make pseudoxml-XML files for display purposes"
|
||||
@echo " linkcheck to check all external links for integrity"
|
||||
@echo " doctest to run all doctests embedded in the documentation (if enabled)"
|
||||
@echo " coverage to run coverage check of the documentation (if enabled)"
|
||||
|
||||
clean:
|
||||
rm -rf $(BUILDDIR)/*
|
||||
|
||||
html:
|
||||
$(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
|
||||
@echo
|
||||
@echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
|
||||
|
||||
dirhtml:
|
||||
$(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml
|
||||
@echo
|
||||
@echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml."
|
||||
|
||||
singlehtml:
|
||||
$(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml
|
||||
@echo
|
||||
@echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml."
|
||||
|
||||
pickle:
|
||||
$(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle
|
||||
@echo
|
||||
@echo "Build finished; now you can process the pickle files."
|
||||
|
||||
json:
|
||||
$(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json
|
||||
@echo
|
||||
@echo "Build finished; now you can process the JSON files."
|
||||
|
||||
htmlhelp:
|
||||
$(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp
|
||||
@echo
|
||||
@echo "Build finished; now you can run HTML Help Workshop with the" \
|
||||
".hhp project file in $(BUILDDIR)/htmlhelp."
|
||||
|
||||
qthelp:
|
||||
$(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp
|
||||
@echo
|
||||
@echo "Build finished; now you can run "qcollectiongenerator" with the" \
|
||||
".qhcp project file in $(BUILDDIR)/qthelp, like this:"
|
||||
@echo "# qcollectiongenerator $(BUILDDIR)/qthelp/redash.qhcp"
|
||||
@echo "To view the help file:"
|
||||
@echo "# assistant -collectionFile $(BUILDDIR)/qthelp/redash.qhc"
|
||||
|
||||
applehelp:
|
||||
$(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp
|
||||
@echo
|
||||
@echo "Build finished. The help book is in $(BUILDDIR)/applehelp."
|
||||
@echo "N.B. You won't be able to view it unless you put it in" \
|
||||
"~/Library/Documentation/Help or install it in your application" \
|
||||
"bundle."
|
||||
|
||||
devhelp:
|
||||
$(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp
|
||||
@echo
|
||||
@echo "Build finished."
|
||||
@echo "To view the help file:"
|
||||
@echo "# mkdir -p $$HOME/.local/share/devhelp/redash"
|
||||
@echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/redash"
|
||||
@echo "# devhelp"
|
||||
|
||||
epub:
|
||||
$(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub
|
||||
@echo
|
||||
@echo "Build finished. The epub file is in $(BUILDDIR)/epub."
|
||||
|
||||
latex:
|
||||
$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
|
||||
@echo
|
||||
@echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex."
|
||||
@echo "Run \`make' in that directory to run these through (pdf)latex" \
|
||||
"(use \`make latexpdf' here to do that automatically)."
|
||||
|
||||
latexpdf:
|
||||
$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
|
||||
@echo "Running LaTeX files through pdflatex..."
|
||||
$(MAKE) -C $(BUILDDIR)/latex all-pdf
|
||||
@echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
|
||||
|
||||
latexpdfja:
|
||||
$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
|
||||
@echo "Running LaTeX files through platex and dvipdfmx..."
|
||||
$(MAKE) -C $(BUILDDIR)/latex all-pdf-ja
|
||||
@echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
|
||||
|
||||
text:
|
||||
$(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text
|
||||
@echo
|
||||
@echo "Build finished. The text files are in $(BUILDDIR)/text."
|
||||
|
||||
man:
|
||||
$(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man
|
||||
@echo
|
||||
@echo "Build finished. The manual pages are in $(BUILDDIR)/man."
|
||||
|
||||
texinfo:
|
||||
$(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
|
||||
@echo
|
||||
@echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo."
|
||||
@echo "Run \`make' in that directory to run these through makeinfo" \
|
||||
"(use \`make info' here to do that automatically)."
|
||||
|
||||
info:
|
||||
$(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
|
||||
@echo "Running Texinfo files through makeinfo..."
|
||||
make -C $(BUILDDIR)/texinfo info
|
||||
@echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo."
|
||||
|
||||
gettext:
|
||||
$(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale
|
||||
@echo
|
||||
@echo "Build finished. The message catalogs are in $(BUILDDIR)/locale."
|
||||
|
||||
changes:
|
||||
$(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes
|
||||
@echo
|
||||
@echo "The overview file is in $(BUILDDIR)/changes."
|
||||
|
||||
linkcheck:
|
||||
$(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck
|
||||
@echo
|
||||
@echo "Link check complete; look for any errors in the above output " \
|
||||
"or in $(BUILDDIR)/linkcheck/output.txt."
|
||||
|
||||
doctest:
|
||||
$(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest
|
||||
@echo "Testing of doctests in the sources finished, look at the " \
|
||||
"results in $(BUILDDIR)/doctest/output.txt."
|
||||
|
||||
coverage:
|
||||
$(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage
|
||||
@echo "Testing of coverage in the sources finished, look at the " \
|
||||
"results in $(BUILDDIR)/coverage/python.txt."
|
||||
|
||||
xml:
|
||||
$(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml
|
||||
@echo
|
||||
@echo "Build finished. The XML files are in $(BUILDDIR)/xml."
|
||||
|
||||
pseudoxml:
|
||||
$(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml
|
||||
@echo
|
||||
@echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml."
|
||||
111
docs/conf.py
Normal file
111
docs/conf.py
Normal file
@@ -0,0 +1,111 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# re:dash documentation build configuration file, created by
|
||||
# sphinx-quickstart on Mon Jul 20 22:40:24 2015.
|
||||
#
|
||||
# This file is execfile()d with the current directory set to its
|
||||
# containing dir.
|
||||
#
|
||||
# Note that not all possible configuration values are present in this
|
||||
# autogenerated file.
|
||||
#
|
||||
# All configuration values have a default; values that are commented out
|
||||
# serve to show the default.
|
||||
|
||||
import sys
|
||||
import os
|
||||
import shlex
|
||||
|
||||
# Add any Sphinx extension module names here, as strings. They can be
|
||||
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
|
||||
# ones.
|
||||
extensions = []
|
||||
|
||||
# The suffix(es) of source filenames.
|
||||
# You can specify multiple suffix as a list of string:
|
||||
# source_suffix = ['.rst', '.md']
|
||||
source_suffix = '.rst'
|
||||
|
||||
# The master toctree document.
|
||||
master_doc = 'index'
|
||||
|
||||
# General information about the project.
|
||||
project = u're:dash'
|
||||
copyright = u'2015, EverythingMe'
|
||||
author = u'EverythingMe'
|
||||
|
||||
# The language for content autogenerated by Sphinx. Refer to documentation
|
||||
# for a list of supported languages.
|
||||
#
|
||||
# This is also used if you do content translation via gettext catalogs.
|
||||
# Usually you set "language" from the command line for these cases.
|
||||
language = None
|
||||
|
||||
exclude_patterns = ['_build']
|
||||
|
||||
# The name of the Pygments (syntax highlighting) style to use.
|
||||
pygments_style = 'sphinx'
|
||||
|
||||
# If true, `todo` and `todoList` produce output, else they produce nothing.
|
||||
todo_include_todos = False
|
||||
|
||||
# -- Options for HTML output ----------------------------------------------
|
||||
# The theme to use for HTML and HTML Help pages. See the documentation for
|
||||
# a list of builtin themes.
|
||||
import sphinx_rtd_theme
|
||||
html_theme = "sphinx_rtd_theme"
|
||||
html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
|
||||
|
||||
# The name for this set of Sphinx documents. If None, it defaults to
|
||||
# "<project> v<release> documentation".
|
||||
#html_title = None
|
||||
|
||||
# A shorter title for the navigation bar. Default is the same as html_title.
|
||||
#html_short_title = None
|
||||
|
||||
# The name of an image file (relative to this directory) to place at the top
|
||||
# of the sidebar.
|
||||
#html_logo = None
|
||||
|
||||
# The name of an image file (within the static path) to use as favicon of the
|
||||
# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
|
||||
# pixels large.
|
||||
#html_favicon = None
|
||||
|
||||
# Add any paths that contain custom static files (such as style sheets) here,
|
||||
# relative to this directory. They are copied after the builtin static files,
|
||||
# so a file named "default.css" will overwrite the builtin "default.css".
|
||||
html_static_path = ['_static']
|
||||
|
||||
# If true, links to the reST sources are added to the pages.
|
||||
html_show_sourcelink = True
|
||||
|
||||
# If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
|
||||
html_show_sphinx = False
|
||||
|
||||
# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
|
||||
html_show_copyright = False
|
||||
|
||||
# Output file base name for HTML help builder.
|
||||
htmlhelp_basename = 'redashdoc'
|
||||
|
||||
# -- Options for manual page output ---------------------------------------
|
||||
|
||||
# One entry per manual page. List of tuples
|
||||
# (source start file, name, description, authors, manual section).
|
||||
man_pages = [
|
||||
(master_doc, 'redash', u're:dash Documentation',
|
||||
[author], 1)
|
||||
]
|
||||
|
||||
# -- Options for Texinfo output -------------------------------------------
|
||||
|
||||
# Grouping the document tree into Texinfo files. List of tuples
|
||||
# (source start file, target name, title, author,
|
||||
# dir menu entry, description, category)
|
||||
texinfo_documents = [
|
||||
(master_doc, 'redash', u're:dash Documentation',
|
||||
author, 'redash', 'One line description of project.',
|
||||
'Miscellaneous'),
|
||||
]
|
||||
|
||||
233
docs/datasources.rst
Normal file
233
docs/datasources.rst
Normal file
@@ -0,0 +1,233 @@
|
||||
Supported Data Sources
|
||||
######################
|
||||
|
||||
re:dash supports several types of data sources, and if you set it up using the provided images, it should already have
|
||||
the needed dependencies to use them all. Starting from version 0.7 and newer, you can manage data sources from the UI
|
||||
by browsing to ``/data_sources`` on your instance.
|
||||
|
||||
If one of the listed data source types isn't available when trying to create a new data source, make sure that:
|
||||
|
||||
1. You installed required dependencies.
|
||||
2. If you've set custom value for the ``REDASH_ENABLED_QUERY_RUNNERS`` setting, it's included in the list.
|
||||
|
||||
PostgreSQL / Redshift / Greenplum
|
||||
---------------------------------
|
||||
|
||||
- **Options**:
|
||||
|
||||
- Database name (mandatory)
|
||||
- User
|
||||
- Password
|
||||
- Host
|
||||
- Port
|
||||
|
||||
- **Additional requirements**:
|
||||
|
||||
- None
|
||||
|
||||
|
||||
MySQL
|
||||
-----
|
||||
|
||||
- **Options**:
|
||||
|
||||
- Database name (mandatory)
|
||||
- User
|
||||
- Password
|
||||
- Host
|
||||
- Port
|
||||
|
||||
- **Additional requirements**:
|
||||
|
||||
- ``MySQL-python`` python package
|
||||
|
||||
|
||||
Google BigQuery
|
||||
---------------
|
||||
|
||||
- **Options**:
|
||||
|
||||
- Project ID (mandatory)
|
||||
- JSON key file, generated when creating a service account (see `instructions <https://developers.google.com/console/help/new/#serviceaccounts>`__).
|
||||
|
||||
|
||||
- **Additional requirements**:
|
||||
|
||||
- ``google-api-python-client``, ``oauth2client`` and ``pyopenssl`` python packages (on Ubuntu it might require installing ``libffi-dev`` and ``libssl-dev`` as well).
|
||||
|
||||
|
||||
Graphite
|
||||
--------
|
||||
|
||||
- **Options**:
|
||||
|
||||
- Url (mandatory)
|
||||
- User
|
||||
- Password
|
||||
- Verify SSL certificate
|
||||
|
||||
|
||||
MongoDB
|
||||
-------
|
||||
|
||||
- **Options**:
|
||||
|
||||
- Connection String (mandatory)
|
||||
- Database name
|
||||
- Replica set name
|
||||
|
||||
- **Additional requirements**:
|
||||
|
||||
- ``pymongo`` python package.
|
||||
|
||||
For information on how to write MongoDB queries, see :doc:`documentation </usage/mongodb_querying>`.
|
||||
|
||||
|
||||
ElasticSearch
|
||||
-------------
|
||||
|
||||
...
|
||||
|
||||
InfluxDB
|
||||
--------
|
||||
|
||||
...
|
||||
|
||||
Presto
|
||||
------
|
||||
|
||||
- **Options**:
|
||||
|
||||
- Host (mandatory)
|
||||
- Address to a Presto coordinator.
|
||||
- Port
|
||||
- Port to a Presto coordinator. `8080` is the default port.
|
||||
- Schema
|
||||
- Default schema name of Presto. You can read other schemas by qualified name like `FROM myschema.table1`.
|
||||
- Catalog
|
||||
- Catalog (connector) name of Presto such as `hive-cdh4`, `hive-hadoop1`, etc.
|
||||
- Username
|
||||
- User name to connect to a Presto.
|
||||
|
||||
- **Additional requirements**:
|
||||
|
||||
- ``pyhive`` python package.
|
||||
|
||||
Hive
|
||||
----
|
||||
|
||||
...
|
||||
|
||||
Impala
|
||||
------
|
||||
|
||||
...
|
||||
|
||||
URL
|
||||
---
|
||||
|
||||
A URL based data source which requests URLs that return the :doc:`results JSON
|
||||
format </dev/results_format>`.
|
||||
|
||||
Very useful in situations where you want to expose the data without
|
||||
connecting directly to the database.
|
||||
|
||||
The query itself inside re:dash will simply contain the URL to be
|
||||
executed (i.e. http://myserver/path/myquery)
|
||||
|
||||
- **Options**:
|
||||
|
||||
- Url - set this if you want to limit queries to certain base path.
|
||||
|
||||
|
||||
Google Spreadsheets
|
||||
-------------------
|
||||
|
||||
- **Options**:
|
||||
|
||||
- JSON key file, generated when creating a service account (see `instructions <https://developers.google.com/console/help/new/#serviceaccounts>`__).
|
||||
|
||||
- **Additional requirements**:
|
||||
|
||||
- ``gspread`` and ``oauth2client`` python packages.
|
||||
|
||||
Notes:
|
||||
|
||||
1. To be able to load the spreadsheet in re:dash - share your it with
|
||||
your ServiceAccount's email (it can be found in the credentials json
|
||||
file, for example
|
||||
43242343247-fjdfakljr3r2@developer.gserviceaccount.com).
|
||||
2. The query format is "DOC\_UUID\|SHEET\_NUM" (for example
|
||||
"kjsdfhkjh4rsEFSDFEWR232jkddsfh\|0")
|
||||
|
||||
|
||||
Python
|
||||
------
|
||||
|
||||
**Execute other queries, manipulate and compute with Python code**
|
||||
|
||||
This is a special query runner, that will execute provided Python code as the query. Useful for various scenarios such as
|
||||
merging data from different data sources, doing data transformation/manipulation that isn't trivial with SQL, merging
|
||||
with remote data or using data analysis libraries such as Pandas (see `example query <https://gist.github.com/arikfr/be7c2888520c44cf4f0f>`__).
|
||||
|
||||
While the Python query runner uses a sandbox (RestrictedPython), it's not 100% secure and the security depends on the
|
||||
modules you allow to import. We recommend enabling the Python query runner only in a trusted environment (meaning: behind
|
||||
VPN and with users you trust).
|
||||
|
||||
- **Options**:
|
||||
|
||||
- Allowed Modules in a comma separated list (optional). **NOTE:**
|
||||
You MUST make sure these modules are installed on the machine
|
||||
running the Celery workers.
|
||||
|
||||
Notes:
|
||||
|
||||
- For security, the python query runner is disabled by default.
|
||||
To enable, add ``redash.query_runner.python`` to the ``REDASH_ADDITIONAL_QUERY_RUNNERS`` environmental variable. If you used
|
||||
the bootstrap script, or one of the provided images, add to ``/opt/redash/.env`` file the line: ``export REDASH_ADDITIONAL_QUERY_RUNNERS=redash.query_runner.python``.
|
||||
|
||||
|
||||
Vertica
|
||||
-----
|
||||
|
||||
- **Options**:
|
||||
|
||||
- Database (mandatory)
|
||||
- User
|
||||
- Password
|
||||
- Host
|
||||
- Port
|
||||
|
||||
- **Additional requirements**:
|
||||
|
||||
- ``vertica-python`` python package
|
||||
|
||||
Oracle
|
||||
------
|
||||
|
||||
- **Options**
|
||||
|
||||
- DSN Service name
|
||||
- User
|
||||
- Password
|
||||
- Host
|
||||
- Port
|
||||
|
||||
- **Additional requirements**
|
||||
|
||||
- ``cx_Oracle`` python package. This requires the installation of the Oracle `instant client <http://www.oracle.com/technetwork/database/features/instant-client/index-097480.html>`__.
|
||||
|
||||
Treasure Data
|
||||
------
|
||||
|
||||
- **Options**
|
||||
|
||||
- Type (TreasureData)
|
||||
- API Key
|
||||
- Database Name
|
||||
- Type (Presto/Hive[default])
|
||||
|
||||
- **Additional requirements**
|
||||
- Must have account on https://console.treasuredata.com
|
||||
|
||||
Documentation: https://docs.treasuredata.com/articles/redash
|
||||
11
docs/dev.rst
Normal file
11
docs/dev.rst
Normal file
@@ -0,0 +1,11 @@
|
||||
Developer Information
|
||||
=====================
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
:glob:
|
||||
|
||||
dev/vagrant
|
||||
dev/*
|
||||
|
||||
|
||||
94
docs/dev/query_execution.rst
Normal file
94
docs/dev/query_execution.rst
Normal file
@@ -0,0 +1,94 @@
|
||||
Query Execution Model
|
||||
#####################
|
||||
|
||||
Introduction
|
||||
============
|
||||
|
||||
The first datasource which was used with re:dash was Redshift. Because
|
||||
we had billions of records in Redshift, and some queries were costly to
|
||||
re-run, from the get go there was the idea of caching query results in
|
||||
re:dash.
|
||||
|
||||
This was to relieve stress from the Redshift cluster and also to improve
|
||||
user experience.
|
||||
|
||||
How queries get executed and cached in re:dash?
|
||||
===============================================
|
||||
|
||||
Server
|
||||
------
|
||||
|
||||
To make sure each query is executed only once at any giving time, we
|
||||
translate the query to a ``query hash``, using the following code:
|
||||
|
||||
.. code:: python
|
||||
|
||||
COMMENTS_REGEX = re.compile("/\*.*?\*/")
|
||||
|
||||
def gen_query_hash(sql):
|
||||
sql = COMMENTS_REGEX.sub("", sql)
|
||||
sql = "".join(sql.split()).lower()
|
||||
return hashlib.md5(sql.encode('utf-8')).hexdigest()
|
||||
|
||||
When query execution is done, the result gets stored to
|
||||
``query_results`` table. Also we check for all queries in the
|
||||
``queries`` table that have the same query hash and update their
|
||||
reference to the query result we just saved
|
||||
(`code <https://github.com/getredash/redash/blob/master/redash/models.py#L235>`__).
|
||||
|
||||
Client
|
||||
------
|
||||
|
||||
The client (UI) will execute queries in two scenarios:
|
||||
|
||||
1. (automatically) When opening a query page of a query that doesn't
|
||||
have a result yet.
|
||||
2. (manually) When the user clicks on "Execute".
|
||||
|
||||
In each case the client does a POST request to ``/api/query_results``
|
||||
with the following parameters: ``query`` (the query text),
|
||||
``data_source_id`` (data source to execute the query with) and ``ttl``.
|
||||
|
||||
When loading a cached result, ``ttl`` will be the one set to the query
|
||||
(if it was set). This is a relic from previous versions, and I'm not
|
||||
sure if it's really used anymore, as usually we will fetch query result
|
||||
using its id.
|
||||
|
||||
When loading a non cached result, ``ttl`` will be 0 which will "force"
|
||||
the server to execute the query.
|
||||
|
||||
As a response to ``/api/query_results`` the server will send either the
|
||||
query results (in case of a cached query) or job id of the currently
|
||||
executing query. When job id received the client will start polling on
|
||||
this id, until a query result received (this is encapsulated in
|
||||
``Query`` and ``QueryResult`` services).
|
||||
|
||||
Ideas on how to implement query parameters
|
||||
==========================================
|
||||
|
||||
Client side only implementation
|
||||
-------------------------------
|
||||
|
||||
(This was actually implemented in. See pull request `#363 <https://github.com/getredash/redash/pull/363>`__ for details.)
|
||||
|
||||
The basic idea of how to implement parametized queries is to treat the
|
||||
query as a template and merge it with parameters taken from query string
|
||||
or UI (or both).
|
||||
|
||||
When the caching facility isn't required (with queries that return in a
|
||||
reasonable time frame) the implementation can be completly client side
|
||||
and the backend can be "blind" to the parameters - it just receives the
|
||||
final query to execute and returns result.
|
||||
|
||||
As one improvement over this, we can let the UI/user specify the TTL
|
||||
value when making the request to ``/api/query_results``, in which case
|
||||
caching will be availble too, while not having to make the server aware
|
||||
of the parameters.
|
||||
|
||||
Hybrid
|
||||
------
|
||||
|
||||
Another option, will be to store the list of possible parameters for a
|
||||
query, with their default/optional values. In such case, the server can
|
||||
prefetch all the options and cache them to provide faster results to the
|
||||
client.
|
||||
30
docs/dev/results_format.rst
Normal file
30
docs/dev/results_format.rst
Normal file
@@ -0,0 +1,30 @@
|
||||
Data Source Results Format
|
||||
==========================
|
||||
|
||||
All data sources in re:dash return the following results in JSON format:
|
||||
|
||||
.. code:: javascript
|
||||
|
||||
{
|
||||
"columns" : [
|
||||
{
|
||||
// Required: a unique identifier of the column name in this result
|
||||
"name" : "COLUMN_NAME",
|
||||
// Required: friendly name of the column that will appear in the results
|
||||
"friendly_name" : "FRIENDLY_NAME",
|
||||
// Optional: If not specified sort might not work well.
|
||||
// Supported types: integer, float, boolean, string (default), datetime (ISO-8601 text format)
|
||||
"type" : "VALUE_TYPE"
|
||||
},
|
||||
...
|
||||
],
|
||||
"rows" : [
|
||||
{
|
||||
// name is the column name as it appears in the columns above.
|
||||
// VALUE is a valid JSON value. For dates its an ISO-8601 string.
|
||||
"name" : VALUE,
|
||||
"name2" : VALUE2
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
38
docs/dev/vagrant.rst
Normal file
38
docs/dev/vagrant.rst
Normal file
@@ -0,0 +1,38 @@
|
||||
Setting up development environment (using Vagrant)
|
||||
==================================================
|
||||
|
||||
To simplify contribution there is a `Vagrant
|
||||
box <https://vagrantcloud.com/redash/boxes/dev>`__ available with all
|
||||
the needed software to run re:dash for development (use it only for
|
||||
development, for demo purposes there is
|
||||
`redash/demo <https://vagrantcloud.com/redash/boxes/demo>`__ box and the
|
||||
AWS/GCE images).
|
||||
|
||||
To get started with this box:
|
||||
|
||||
1. Make sure you have recent version of
|
||||
`Vagrant <https://www.vagrantup.com/>`__ installed.
|
||||
2. Clone the re:dash repository:
|
||||
``git clone https://github.com/getredash/redash.git``.
|
||||
3. Change dir into the repository (``cd redash``) and run run
|
||||
``vagrant up``. This might take some time the first time you run it,
|
||||
as it downloads the Vagrant virtual box.
|
||||
4. Once Vagrant is ready, ssh into the instance (``vagrant ssh``), and
|
||||
change dir to ``/opt/redash/current`` -- this is where your local
|
||||
repository copy synced to.
|
||||
5. Copy ``.env`` file into this directory (``cp ../.env ./``).
|
||||
6. From ``/opt/redash/current/rd_ui`` run ``bower install`` to install
|
||||
frontend packages. This can be done from your host machine as well,
|
||||
if you have bower installed.
|
||||
7. Go back to ``/opt/redash/current`` and install python dependencies
|
||||
``sudo pip install -r requirements.txt``
|
||||
8. Apply migrations
|
||||
|
||||
::
|
||||
|
||||
export PYTHONPATH=. && find migrations/ -type f | grep 00 --null | xargs -I file bin/run python file
|
||||
|
||||
9. Start the server and background workers with
|
||||
``bin/run honcho start -f Procfile.dev``.
|
||||
10. Now the server should be available on your host on port 9001 and you
|
||||
can login with username admin and password admin.
|
||||
57
docs/index.rst
Normal file
57
docs/index.rst
Normal file
@@ -0,0 +1,57 @@
|
||||
.. image:: http://redash.io/static/old_img/redash_logo.png
|
||||
:width: 200px
|
||||
|
||||
Open Source Data Collaboration and Visualization Platform
|
||||
===================================
|
||||
|
||||
**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 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,Google Spreadsheets, PostgreSQL, MySQL, Graphite and custom scripts.
|
||||
|
||||
Features
|
||||
########
|
||||
|
||||
1. **Query Editor**: think of `JS Fiddle`_ 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.
|
||||
2. **Visualizations**: once you have a dataset, you can create different visualizations out of it. Currently it supports charts, pivot table and cohorts.
|
||||
3. **Dashboards**: combine several visualizations into a single dashboard.
|
||||
|
||||
Demo
|
||||
####
|
||||
|
||||
.. figure:: https://raw.github.com/getredash/redash/screenshots/screenshots.gif
|
||||
:alt: Screenshots
|
||||
|
||||
You can try out the demo instance: `http://demo.redash.io`_ (login with any Google account).
|
||||
|
||||
.. _http://demo.redash.io: http://demo.redash.io
|
||||
.. _JS Fiddle: http://jsfiddle.net
|
||||
|
||||
Getting Started
|
||||
###############
|
||||
|
||||
:doc:`Setting up re:dash instance </setup>` (includes links to ready made AWS/GCE images).
|
||||
|
||||
Getting Help
|
||||
############
|
||||
|
||||
* Source: https://github.com/getredash/redash
|
||||
* Issues: https://github.com/getredash/redash/issues
|
||||
* Mailing List: https://groups.google.com/forum/#!forum/redash-users
|
||||
* Gitter (chat): https://gitter.im/getredash/redash
|
||||
* Contact Arik, the maintainer directly: arik@redash.io.
|
||||
|
||||
TOC
|
||||
###
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
|
||||
setup
|
||||
upgrade
|
||||
datasources
|
||||
usage
|
||||
dev
|
||||
misc
|
||||
10
docs/misc.rst
Normal file
10
docs/misc.rst
Normal file
@@ -0,0 +1,10 @@
|
||||
Miscellaneous
|
||||
=============
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
:glob:
|
||||
|
||||
misc/*
|
||||
|
||||
|
||||
74
docs/misc/backup_restore.rst
Normal file
74
docs/misc/backup_restore.rst
Normal file
@@ -0,0 +1,74 @@
|
||||
How To: Backup your re:dash database and restore it on a different server
|
||||
=================
|
||||
|
||||
**Note:** This guide assumes that the default database name (redash) has not been changed.
|
||||
|
||||
1. Check the size of your redash database. This can be done by creating a query within redash itself against the 're:dash metadata' data source.
|
||||
|
||||
.. code::
|
||||
|
||||
select t1.datname AS db_name, pg_size_pretty(pg_database_size(t1.datname)) as db_size
|
||||
from pg_database t1
|
||||
where t1.datname = 'redash'
|
||||
|
||||
|
||||
2. Check the amount of available disk space on your existing server.
|
||||
|
||||
.. code::
|
||||
|
||||
df -hT
|
||||
|
||||
|
||||
3. Backup the existing redash database.
|
||||
|
||||
.. code::
|
||||
|
||||
sudo -u redash pg_dump redash | gzip > redash_backup.gz
|
||||
|
||||
|
||||
4. Transfer the backup to the new server.
|
||||
|
||||
5. `Perform a clean install of re:dash <http://docs.redash.io/en/latest/setup.html>`__ on the new server.
|
||||
|
||||
6. Check the amount of available disk space on the new server.
|
||||
|
||||
.. code::
|
||||
|
||||
df -hT
|
||||
|
||||
|
||||
7. Login as postgres user on the new server.
|
||||
|
||||
.. code::
|
||||
|
||||
sudo -u postgres -i
|
||||
|
||||
|
||||
8. drop the current redash database, create a new database named redash, and then restore the backup into the new database.
|
||||
|
||||
.. code::
|
||||
|
||||
dbdrop redash
|
||||
createdb -T template0 redash
|
||||
gunzip -c redash_backup.gz | psql redash
|
||||
|
||||
|
||||
9. Set a new password of your choosing for the 'redash_reader' user (since the new installation generated a random password).
|
||||
|
||||
.. code::
|
||||
|
||||
psql -c "ALTER ROLE redash_reader WITH PASSWORD 'yourpasswordgoeshere';"
|
||||
|
||||
|
||||
**Note:** Then you must navigate to the 're:dash metadata' data source (/data_sources/1) in the new re:dash installation and change the password to match the one entered above.
|
||||
|
||||
10. Grant permissions on the redash database to the redash_reader user.
|
||||
|
||||
.. code::
|
||||
|
||||
psql -c "grant select(id,name,type) ON data_sources to redash_reader;" redash
|
||||
psql -c "grant select(id,name) ON users to redash_reader;" redash
|
||||
psql -c "grant select on activity_log, events, queries, dashboards, widgets, visualizations, query_results to redash_reader;" redash
|
||||
|
||||
|
||||
Create a new query in redash (using re:dash metadata as the data source) to test that everything is working as expected.
|
||||
50
docs/misc/google_developers_project.rst
Normal file
50
docs/misc/google_developers_project.rst
Normal file
@@ -0,0 +1,50 @@
|
||||
How To: Create a Google Developers Project
|
||||
==========================================
|
||||
|
||||
1. Go to the `Google Developers
|
||||
Console <https://console.developers.google.com/>`__.
|
||||
2. Select a project, or create a new one by clicking Create Project:
|
||||
|
||||
1. In the Project name field, type in a name for your project.
|
||||
2. In the Project ID field, optionally type in a project ID for your
|
||||
project or use the one that the console has created for you. This
|
||||
ID must be unique world-wide.
|
||||
3. Click the **Create** button and wait for the project to be
|
||||
created.
|
||||
4. Click on the new project name in the list to start editing the
|
||||
project.
|
||||
|
||||
3. In the left sidebar, select the **APIs** item below "APIs & auth". A
|
||||
list of Google web services appears.
|
||||
4. Find the **Google+ API** service and set its status to **ON**—notice
|
||||
that this action moves the service to the top of the list.
|
||||
5. In the sidebar under "APIs & auth", select **Credentials** and in that screen choose the **OAuth consent screen** tab
|
||||
|
||||
- Choose an Email Address and specify a Product Name.
|
||||
|
||||
6. In the sidebar under "APIs & auth", select **Credentials**.
|
||||
7. Click **Add Credentials** button and choose **OAuth 20 Client ID**.
|
||||
|
||||
- In the **Application type** section of the dialog, select **Web
|
||||
application**.
|
||||
- In the **Authorized JavaScript origins** field, enter the origin
|
||||
for your app. You can enter multiple origins to use with multiple
|
||||
re:dash instance. Wildcards are not allowed. In the example below,
|
||||
we assume your re:dash instance address is *redash.example.com*:
|
||||
|
||||
::
|
||||
|
||||
http://redash.example.com
|
||||
https://redash.example.com
|
||||
|
||||
- In the Authorized redirect URI field, enter the redirect URI
|
||||
callback:
|
||||
|
||||
::
|
||||
|
||||
http://redash.example.com/oauth/google_callback
|
||||
|
||||
- Click the ``Create`` button.
|
||||
|
||||
8. In the resulting **Client ID for web application** section, copy the
|
||||
**Client ID** and **Client secret** to your ``.env`` file.
|
||||
141
docs/misc/letsencrypt.rst
Normal file
141
docs/misc/letsencrypt.rst
Normal file
@@ -0,0 +1,141 @@
|
||||
How To: Encrypt your re:dash installation with a free SSL certificate from Let's Encrypt
|
||||
=================
|
||||
|
||||
**Note:** This below steps were tested on Ubuntu 14.04, but *should* work with any Debian-based distro.
|
||||
|
||||
`Let's Encrypt <https://letsencrypt.org/>`__ is a new certificate authority sponsored by major tech companies including Mozilla, Google, Cisco, and Facebook. Unlike traditional CA authorities, Let's Encrypt allows you to generate and renew an SSL certificate quickly and **at no cost**.
|
||||
|
||||
1. Open port 443 in your security group (if using AWS or GCE).
|
||||
|
||||
2. Update package lists, install git, and clone the letsencrypt repository.
|
||||
|
||||
.. code::
|
||||
|
||||
sudo apt-get update
|
||||
sudo apt-get install git
|
||||
sudo git clone https://github.com/letsencrypt/letsencrypt /opt/letsencrypt
|
||||
|
||||
|
||||
3. Stop nginx and redash, then ensure that no processes are still listening on port 80.
|
||||
|
||||
.. code::
|
||||
|
||||
sudo supervisorctl stop redash_server
|
||||
sudo service nginx stop
|
||||
netstat -na | grep ':80.*LISTEN'
|
||||
|
||||
|
||||
4. Generate your letsencrypt certificate.
|
||||
|
||||
.. code::
|
||||
|
||||
cd /opt/letsencrypt
|
||||
sudo pip install urllib3[secure] --upgrade
|
||||
./letsencrypt-auto certonly --standalone
|
||||
|
||||
|
||||
In most cases you'll want to enter 'example.com www.example.com' when prompted for your domain so that you can use the certificate on http://example.com and http://www.example.com.
|
||||
|
||||
5. Optionally generate a stronger Diffie-Hellman ephemeral parameter. Without this step, you will not achieve higher than a B score on `SSLLabs <https://www.ssllabs.com/ssltest/>`__. Please note that on a low-end server (VPS or micro/small GCE instance) this step can take approximately 20-30 minutes.
|
||||
|
||||
.. code::
|
||||
|
||||
cd /etc/ssl/certs
|
||||
sudo openssl dhparam -out dhparam.pem 3072
|
||||
|
||||
|
||||
6. Backup the existing nginx redash config, delete it, and then create a new version with the code supplied below.
|
||||
|
||||
.. code::
|
||||
|
||||
sudo cp /etc/nginx/sites-available/redash /etc/nginx/sites-available/redash.bak
|
||||
sudo rm /etc/nginx/sites-available/redash
|
||||
sudo nano /etc/nginx/sites-available/redash
|
||||
|
||||
|
||||
.. code:: nginx
|
||||
|
||||
upstream redash_servers {
|
||||
server 127.0.0.1:5000;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
|
||||
# Allow accessing /ping without https. Useful when placing behind load balancer.
|
||||
location /ping {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_pass http://redash_servers;
|
||||
}
|
||||
|
||||
location / {
|
||||
# Enforce SSL.
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
ssl on;
|
||||
|
||||
# Make sure to set paths to your certificate .pem and .key files.
|
||||
ssl_certificate /etc/letsencrypt/live/YOURDOMAIN.TLD/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/YOURDOMAIN.TLD/privkey.pem;
|
||||
ssl_dhparam /etc/ssl/certs/dhparam.pem;
|
||||
|
||||
# Use secure protocols and ciphers which are compatible with modern browsers
|
||||
ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_ciphers AES256+EECDH:AES256+EDH;
|
||||
ssl_session_cache shared:SSL:20m;
|
||||
|
||||
# Enforce strict transport security
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubdomains;";
|
||||
|
||||
access_log /var/log/nginx/redash.access.log;
|
||||
|
||||
gzip on;
|
||||
gzip_types *;
|
||||
gzip_proxied any;
|
||||
|
||||
location / {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_pass http://redash_servers;
|
||||
proxy_redirect off;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
7. Start the nginx and redash servers again.
|
||||
|
||||
.. code::
|
||||
|
||||
sudo service nginx start
|
||||
sudo supervisorctl start redash_server
|
||||
|
||||
|
||||
8. Verify the installation by running a `SSLLabs test <https://www.ssllabs.com/ssltest/>`__. This guide *should* yield an A+ score. If everything is working as expected, optionally delete the old redash nginx config:
|
||||
|
||||
.. code::
|
||||
|
||||
sudo rm /etc/nginx/sites-available/redash.bak
|
||||
|
||||
|
||||
**Important Note:** letsencrypt certificates only remain valid for 90 days. To renew your certificate, simply follow steps 3 and 4 again:
|
||||
|
||||
.. code::
|
||||
|
||||
sudo supervisorctl stop redash_server
|
||||
sudo service nginx stop
|
||||
netstat -na | grep ':80.*LISTEN'
|
||||
|
||||
cd /opt/letsencrypt
|
||||
./letsencrypt-auto certonly --standalone
|
||||
|
||||
sudo service nginx start
|
||||
sudo supervisorctl start redash_server
|
||||
59
docs/misc/ssl.rst
Normal file
59
docs/misc/ssl.rst
Normal file
@@ -0,0 +1,59 @@
|
||||
SSL (HTTPS) Setup
|
||||
=================
|
||||
|
||||
If you used the provided images or the bootstrap script, to start using
|
||||
SSL with your instance you need to:
|
||||
|
||||
1. Update the nginx config file (``/etc/nginx/sites-available/redash``)
|
||||
with SSL configuration (see below an example). Make sure to upload
|
||||
the certificate to the server, and set the paths correctly in the new
|
||||
config.
|
||||
|
||||
2. Open port 443 in your security group (if using AWS or GCE).
|
||||
|
||||
.. code:: nginx
|
||||
|
||||
upstream redash_servers {
|
||||
server 127.0.0.1:5000;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
|
||||
# Allow accessing /ping without https. Useful when placing behind load balancer.
|
||||
location /ping {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_pass http://redash_servers;
|
||||
}
|
||||
|
||||
location / {
|
||||
# Enforce SSL.
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
|
||||
# Make sure to set paths to your certificate .pem and .key files.
|
||||
ssl on;
|
||||
ssl_certificate /path-to/cert.pem; # or crt
|
||||
ssl_certificate_key /path-to/cert.key;
|
||||
|
||||
access_log /var/log/nginx/redash.access.log;
|
||||
|
||||
gzip on;
|
||||
gzip_types *;
|
||||
gzip_proxied any;
|
||||
|
||||
location / {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_pass http://redash_servers;
|
||||
proxy_redirect off;
|
||||
}
|
||||
}
|
||||
3
docs/requirements.txt
Normal file
3
docs/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
sphinx
|
||||
sphinx-autobuild
|
||||
sphinx_rtd_theme
|
||||
179
docs/setup.rst
Normal file
179
docs/setup.rst
Normal file
@@ -0,0 +1,179 @@
|
||||
Setting up re:dash instance
|
||||
###########################
|
||||
|
||||
The `provisioning
|
||||
script <https://raw.githubusercontent.com/getredash/redash/master/setup/ubuntu/bootstrap.sh>`__
|
||||
works on Ubuntu 12.04, Ubuntu 14.04 and Debian Wheezy. This script
|
||||
installs all needed dependencies and creates basic setup.
|
||||
|
||||
To ease the process, there are also images for AWS and Google Compute
|
||||
Cloud. These images created with the same provision script using Packer.
|
||||
|
||||
Create an instance
|
||||
==================
|
||||
|
||||
AWS
|
||||
---
|
||||
|
||||
Launch the instance with from the pre-baked AMI (for small deployments
|
||||
t2.micro should be enough):
|
||||
|
||||
- us-east-1: `ami-752c7f10 <https://console.aws.amazon.com/ec2/home?region=us-east-1#LaunchInstanceWizard:ami=ami-752c7f10>`__
|
||||
- us-west-1: `ami-b36babf7 <https://console.aws.amazon.com/ec2/home?region=us-west-1#LaunchInstanceWizard:ami=ami-b36babf7>`__
|
||||
- us-west-2: `ami-a0a04393 <https://console.aws.amazon.com/ec2/home?region=us-west-2#LaunchInstanceWizard:ami=ami-a0a04393>`__
|
||||
- eu-west-1: `ami-198cb16e <https://console.aws.amazon.com/ec2/home?region=eu-west-1#LaunchInstanceWizard:ami=ami-198cb16e>`__
|
||||
- eu-central-1: `ami-a81418b5 <https://console.aws.amazon.com/ec2/home?region=eu-central-1#LaunchInstanceWizard:ami=ami-a81418b5>`__
|
||||
- sa-east-1: `ami-2b52c336 <https://console.aws.amazon.com/ec2/home?region=sa-east-1#LaunchInstanceWizard:ami=ami-2b52c336>`__
|
||||
- ap-northeast-1: `ami-4898fb48 <https://console.aws.amazon.com/ec2/home?region=ap-northeast-1#LaunchInstanceWizard:ami=ami-4898fb48>`__
|
||||
- ap-southeast-2: `ami-7559134f <https://console.aws.amazon.com/ec2/home?region=ap-southeast-2#LaunchInstanceWizard:ami=ami-7559134f>`__
|
||||
- ap-southeast-1: `ami-a0786bf2 <https://console.aws.amazon.com/ec2/home?region=ap-southeast-1#LaunchInstanceWizard:ami=ami-a0786bf2>`__
|
||||
|
||||
When launching the instance make sure to use a security grop, that only allows incoming traffic on: port 22 (SSH), 80 (HTTP) and 443 (HTTPS).
|
||||
|
||||
Now proceed to `"Setup" <#setup>`__.
|
||||
|
||||
Google Compute Engine
|
||||
---------------------
|
||||
|
||||
First, you need to add the images to your account:
|
||||
|
||||
.. code:: bash
|
||||
|
||||
$ gcloud compute images create "redash-081-b1110" --source-uri gs://redash-images/redash.0.8.1.b1110.tar.gz
|
||||
|
||||
Next you need to launch an instance using this image (n1-standard-1
|
||||
instance type is recommended). If you plan using re:dash with BigQuery,
|
||||
you can use a dedicated image which comes with BigQuery preconfigured
|
||||
(using instance permissions):
|
||||
|
||||
.. code:: bash
|
||||
|
||||
$ gcloud compute images create "redash-081-b1110-bq" --source-uri gs://redash-images/redash.0.8.1.b1110-bq.tar.gz
|
||||
|
||||
Note that you need to launch this instance with BigQuery access:
|
||||
|
||||
.. code:: bash
|
||||
|
||||
$ gcloud compute instances create <your_instance_name> --image redash-081-b1110-bq --scopes storage-ro,bigquery
|
||||
|
||||
(the same can be done from the web interface, just make sure to enable
|
||||
BigQuery access)
|
||||
|
||||
Now proceed to `"Setup" <#setup>`__.
|
||||
|
||||
|
||||
Other
|
||||
-----
|
||||
|
||||
Download the provision script and run it on your machine. Note that:
|
||||
|
||||
1. You need to run the script as root.
|
||||
2. It was tested only on Ubuntu 12.04, Ubuntu 14.04 and Debian Wheezy.
|
||||
3. It's designed to run on a "clean" machine. If you're running this script on a machine that is used for other purposes, you might want to tweak it to your needs (like removing the ``apt-get dist-upgrade`` call at the beginning of it).
|
||||
|
||||
Setup
|
||||
=====
|
||||
|
||||
Once you created the instance with either the image or the script, you
|
||||
should have a running re:dash instance with everything you need to get
|
||||
started. You can now login to it with the user "admin" (password:
|
||||
"admin"). But to make it useful, there are a few more steps that you
|
||||
need to manually do to complete the setup:
|
||||
|
||||
First ssh to your instance and change directory to ``/opt/redash``. If
|
||||
you're using the GCE image, switch to root (``sudo su``).
|
||||
|
||||
Users & Google Authentication setup
|
||||
-----------------------------------
|
||||
|
||||
Most of the settings you need to edit are in the ``/opt/redash/.env``
|
||||
file.
|
||||
|
||||
1. Update the cookie secret (important! otherwise anyone can sign new
|
||||
cookies and impersonate users): change "veryverysecret" in the line:
|
||||
``export REDASH_COOKIE_SECRET=veryverysecret`` to something else (you
|
||||
can run the command ``pwgen 32 -1`` to generate a random string).
|
||||
|
||||
2. By default we create an admin user with the password "admin". You
|
||||
can change this password opening the: ``/users/me#password`` page after
|
||||
logging in as admin.
|
||||
|
||||
3. If you want to use Google OAuth to authenticate users, you need to
|
||||
create a Google Developers project (see :doc:`instructions </misc/google_developers_project>`)
|
||||
and then add the needed configuration in the ``.env`` file:
|
||||
|
||||
.. code::
|
||||
|
||||
export REDASH_GOOGLE_CLIENT_ID=""
|
||||
export REDASH_GOOGLE_CLIENT_SECRET=""
|
||||
|
||||
|
||||
4. Configure the domain(s) you want to allow to use with Google Apps, by running the command:
|
||||
|
||||
.. code::
|
||||
|
||||
cd /opt/redash/current
|
||||
sudo -u redash bin/run ./manage.py set_google_apps_domains {{domains}}
|
||||
|
||||
|
||||
If you're passing multiple domains, separate them with commas.
|
||||
|
||||
|
||||
5. Restart the web server to apply the configuration changes:
|
||||
``sudo supervisorctl restart redash_server``.
|
||||
|
||||
6. Once you have Google OAuth enabled, you can login using your Google
|
||||
Apps account. If you want to grant admin permissions to some users,
|
||||
you can do this by editing the user profile and enabling admin
|
||||
permission for it.
|
||||
|
||||
7. If you don't use Google OAuth or just need username/password logins,
|
||||
you can create additional users by opening the ``/users/new`` page.
|
||||
|
||||
Datasources
|
||||
-----------
|
||||
|
||||
To make re:dash truly useful, you need to setup your data sources in it. Browse to ``/data_sources`` on your instance,
|
||||
to create new data source connection.
|
||||
|
||||
See :doc:`documentation </datasources>` for the different options.
|
||||
Your instance comes ready with dependencies needed to setup supported sources.
|
||||
|
||||
Mail Configuration
|
||||
------------------
|
||||
|
||||
For the system to be able to send emails (for example when alerts trigger), you need to set the mail server to use and the
|
||||
host name of your re:dash server. If you're using one of our images, you can do this by editing the `.env` file:
|
||||
|
||||
.. code::
|
||||
|
||||
# Note that not all values are required, as they have default values.
|
||||
|
||||
export REDASH_MAIL_SERVER="" # default: localhost
|
||||
export REDASH_MAIL_PORT="" # default: 25
|
||||
export REDASH_MAIL_USE_TLS="" # default: False
|
||||
export REDASH_MAIL_USE_SSL="" # default: False
|
||||
export REDASH_MAIL_USERNAME="" # default: None
|
||||
export REDASH_MAIL_PASSWORD="" # default: None
|
||||
export REDASH_MAIL_DEFAULT_SENDER="" # Email address to send from
|
||||
|
||||
export REDASH_HOST="" # base address of your re:dash instance, for example: "https://demo.redash.io"
|
||||
|
||||
- Note that not all values are required, as there are default values.
|
||||
- It's recommended to use some mail service, like `Amazon SES <https://aws.amazon.com/ses/>`__, `Mailgun <http://www.mailgun.com/>`__
|
||||
or `Mandrill <http://mandrillapp.com>`__ to send emails to ensure deliverability.
|
||||
|
||||
To test email configuration, you can run `bin/run ./manage.py send_test_mail` (from `/opt/redash/current`).
|
||||
|
||||
How to upgrade?
|
||||
---------------
|
||||
|
||||
It's recommended to upgrade once in a while your re:dash instance to
|
||||
benefit from bug fixes and new features. See :doc:`here </upgrade>` for full upgrade
|
||||
instructions (including Fabric script).
|
||||
|
||||
Notes
|
||||
=====
|
||||
|
||||
- If this is a production setup, you should enforce HTTPS and make sure
|
||||
you set the cookie secret (see :doc:`instructions </misc/ssl>`).
|
||||
36
docs/upgrade.rst
Normal file
36
docs/upgrade.rst
Normal file
@@ -0,0 +1,36 @@
|
||||
How to Upgrade
|
||||
##############
|
||||
|
||||
It's recommended to upgrade your re:dash instance once there are new
|
||||
releases, to benefit from new features and bug fixes. The upgrade
|
||||
process is relatively simple, and assuming you used one of the base
|
||||
images we provide, you can just use the
|
||||
`Fabric <http://www.fabfile.org/>`__ script provided here:
|
||||
https://gist.github.com/arikfr/440d1403b4aeb76ebaf8.
|
||||
|
||||
How to run the Fabric script
|
||||
============================
|
||||
|
||||
1. Install Fabric: ``pip install fabric requests`` (needed only once)
|
||||
2. Download the ``fabfile.py`` from the gist.
|
||||
3. Run the script:
|
||||
``fab -H{your re:dash host} -u{the ssh user for this host} -i{path to key file for passwordless login} deploy_latest_release``
|
||||
|
||||
``-i`` is optional and it is only needed in case you're using private-key based authentication (and didn't add the key file to your authentication agent or set its path in your SSH config).
|
||||
|
||||
What the Fabric script does
|
||||
===========================
|
||||
|
||||
Even if you didn't use the image, it's very likely you can reuse most of
|
||||
this script with small modifications. What this script does is:
|
||||
|
||||
1. Find the URL of the latest release tarball (from `GitHub releases
|
||||
page <github.com/getredash/redash/releases>`__).
|
||||
2. Download it.
|
||||
3. Create new directory for this version (for example:
|
||||
``/opt/redash/redash.0.5.0.b685``).
|
||||
4. Unpack that (``tar -C {dir} -xvf {tarball path}``).
|
||||
5. Link ``/opt/redash/.env`` file into this directory.
|
||||
6. Apply any new migrations.
|
||||
7. Link ``/opt/redash/current`` to new version.
|
||||
8. Restart web server and celery workers.
|
||||
11
docs/usage.rst
Normal file
11
docs/usage.rst
Normal file
@@ -0,0 +1,11 @@
|
||||
Usage
|
||||
=====
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
:glob:
|
||||
|
||||
usage/maintenance.rst
|
||||
usage/*
|
||||
|
||||
|
||||
72
docs/usage/elasticsearch_querying.rst
Normal file
72
docs/usage/elasticsearch_querying.rst
Normal file
@@ -0,0 +1,72 @@
|
||||
ElasticSearch: Querying
|
||||
#######################
|
||||
|
||||
ElasticSearch currently supports only simple Lucene style queries (like
|
||||
Kibana but without the aggregation).
|
||||
|
||||
Full blown JSON based ElasticSearch queries (including aggregations)
|
||||
will be added later.
|
||||
|
||||
Simple query example:
|
||||
=====================
|
||||
|
||||
- Query the index named "twitter"
|
||||
- Filter by "user:kimchy"
|
||||
- Return the fields: "@timestamp", "tweet" and "user"
|
||||
- Return up to 15 results
|
||||
- Sort by @timestamp ascending
|
||||
|
||||
.. code:: json
|
||||
|
||||
{
|
||||
"index" : "twitter",
|
||||
"query" : "user:kimchy",
|
||||
"fields" : ["@timestamp", "tweet", "user"],
|
||||
"size" : 15,
|
||||
"sort" : "@timestamp:asc"
|
||||
}
|
||||
|
||||
Simple query on a logstash ElasticSearch instance:
|
||||
==================================================
|
||||
|
||||
- Query the index named "logstash-2015.04.\*" (in this case its all of
|
||||
April 2015)
|
||||
- Filter by type:events AND eventName:UserUpgrade AND channel:selfserve
|
||||
- Return fields: "@timestamp", "userId", "channel", "utm\_source",
|
||||
"utm\_medium", "utm\_campaign", "utm\_content"
|
||||
- Return up to 250 results
|
||||
- Sort by @timestamp ascending
|
||||
|
||||
.. code:: json
|
||||
|
||||
{
|
||||
"index" : "logstash-2015.04.*",
|
||||
"query" : "type:events AND eventName:UserUpgrade AND channel:selfserve",
|
||||
"fields" : ["@timestamp", "userId", "channel", "utm_source", "utm_medium", "utm_campaign", "utm_content"],
|
||||
"size" : 250,
|
||||
"sort" : "@timestamp:asc"
|
||||
}
|
||||
|
||||
Simple query on a ElasticSearch instance:
|
||||
==================================================
|
||||
|
||||
|
||||
- Query the index named "twitter"
|
||||
- Filter by user equal "kimchy"
|
||||
- Return the fields: "@timestamp", "tweet" and "user"
|
||||
- Return up to 15 results
|
||||
- Sort by @timestamp ascending
|
||||
|
||||
.. code:: json
|
||||
|
||||
{
|
||||
"index" : "twitter",
|
||||
"query" : {
|
||||
"match": {
|
||||
"user" : "kimchy"
|
||||
}
|
||||
},
|
||||
"fields" : ["@timestamp", "tweet", "user"],
|
||||
"size" : 15,
|
||||
"sort" : "@timestamp:asc"
|
||||
}
|
||||
70
docs/usage/maintenance.rst
Normal file
70
docs/usage/maintenance.rst
Normal file
@@ -0,0 +1,70 @@
|
||||
Ongoing Maintanence and Basic Operations
|
||||
########################################
|
||||
|
||||
Configuration and logs
|
||||
======================
|
||||
|
||||
The supervisor config can be found in
|
||||
``/opt/redash/supervisord/supervisord.conf``.
|
||||
|
||||
There you can see the names of its programs (``redash_celery``,
|
||||
``redash_server``) and the location of their logs.
|
||||
|
||||
Restart
|
||||
=======
|
||||
|
||||
Restarting the Web Server
|
||||
-------------------------
|
||||
|
||||
``sudo supervisorctl stop redash_server``
|
||||
|
||||
Restarting Celery Workers
|
||||
-------------------------
|
||||
|
||||
``sudo supervisorctl restart redash_celery``
|
||||
|
||||
Restarting Celery Workers & the Queries Queue
|
||||
---------------------------------------------
|
||||
|
||||
In case you are handling a problem, and you need to stop the currently
|
||||
running queries and reset the queue, follow the steps below.
|
||||
|
||||
1. Stop celery: ``sudo supervisorctl stop redash_celery`` (celery might
|
||||
take some time to stop, if it's in the middle of running a query)
|
||||
|
||||
2. Flush redis: ``redis-cli flushall``.
|
||||
|
||||
3. Start celery: ``sudo supervisorctl start redash_celery``
|
||||
|
||||
Changing the Number of Workers
|
||||
==============================
|
||||
|
||||
By default, Celery will start a worker per CPU core. Because most of
|
||||
re:dash's tasks are IO bound, the real limit for number of workers you
|
||||
can use depends on the amount of memory your machine has. It's
|
||||
recommended to increase number of workers, to support more concurrent
|
||||
queries.
|
||||
|
||||
1. Open the supervisord configuration file:
|
||||
``/opt/redash/supervisord/supervisord.conf``
|
||||
|
||||
2. Edit the ``[program:redash_celery]`` section and add to the *command*
|
||||
value, the param "-c" with the number of concurrent workers you need.
|
||||
|
||||
3. Restart supervisord to apply new configuration:
|
||||
``sudo /etc/init.d/redash_supervisord restart``.
|
||||
|
||||
DB
|
||||
==
|
||||
|
||||
Backup re:dash's DB:
|
||||
--------------------
|
||||
|
||||
``sudo -u redash pg_dump > backup_filename.sql``
|
||||
|
||||
Version
|
||||
=======
|
||||
|
||||
See current version:
|
||||
|
||||
``bin/run ./manage.py version``
|
||||
74
docs/usage/mongodb_querying.rst
Normal file
74
docs/usage/mongodb_querying.rst
Normal file
@@ -0,0 +1,74 @@
|
||||
MongoDB: Querying
|
||||
#################
|
||||
|
||||
Simple query example:
|
||||
=====================
|
||||
|
||||
.. code:: json
|
||||
|
||||
{
|
||||
"collection" : "my_collection",
|
||||
"query" : {
|
||||
"date" : {
|
||||
"$gt" : "ISODate(\"2015-01-15 11:41\")",
|
||||
},
|
||||
"type" : 1
|
||||
},
|
||||
"fields" : {
|
||||
"_id" : 1,
|
||||
"name" : 2
|
||||
},
|
||||
"sort" : [
|
||||
{
|
||||
"name" : "date",
|
||||
"direction" : -1
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Live example on the demo instance:
|
||||
http://demo.redash.io/queries/394/source.
|
||||
|
||||
Aggregation
|
||||
===========
|
||||
|
||||
Uses a syntax similar to the one used in PyMongo, however to support the
|
||||
correct order of sorting, it uses a regular list for the "$sort"
|
||||
operation that converts into a SON (sorted dictionary) object before
|
||||
execution.
|
||||
|
||||
Aggregation query example:
|
||||
|
||||
.. code:: json
|
||||
|
||||
{
|
||||
"collection" : "things",
|
||||
"aggregate" : [
|
||||
{
|
||||
"$unwind" : "$tags"
|
||||
},
|
||||
{
|
||||
"$group" : {
|
||||
"_id" : "$tags",
|
||||
"count" : { "$sum" : 1 }
|
||||
}
|
||||
},
|
||||
{
|
||||
"$sort" : [
|
||||
{
|
||||
"name" : "count",
|
||||
"direction" : -1
|
||||
},
|
||||
{
|
||||
"name" : "_id",
|
||||
"direction" : -1
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Live examples on the demo instance:
|
||||
|
||||
1. http://demo.redash.io/queries/393/source
|
||||
2. http://demo.redash.io/queries/387/source
|
||||
25
manage.py
25
manage.py
@@ -2,18 +2,21 @@
|
||||
"""
|
||||
CLI to manage redash.
|
||||
"""
|
||||
import json
|
||||
|
||||
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
|
||||
from redash.cli import users, database, data_sources, organization
|
||||
from redash.monitor import get_status
|
||||
|
||||
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.add_command("org", organization.manager)
|
||||
|
||||
|
||||
|
||||
@manager.command
|
||||
@@ -21,6 +24,9 @@ def version():
|
||||
"""Displays re:dash version."""
|
||||
print __version__
|
||||
|
||||
@manager.command
|
||||
def status():
|
||||
print json.dumps(get_status(), indent=2)
|
||||
|
||||
@manager.command
|
||||
def runworkers():
|
||||
@@ -37,12 +43,15 @@ def make_shell_context():
|
||||
@manager.command
|
||||
def check_settings():
|
||||
"""Show the settings as re:dash sees them (useful for debugging)."""
|
||||
from types import ModuleType
|
||||
for name, item in settings.all_settings().iteritems():
|
||||
print "{} = {}".format(name, item)
|
||||
|
||||
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)
|
||||
@manager.command
|
||||
def send_test_mail():
|
||||
from redash import mail
|
||||
from flask_mail import Message
|
||||
|
||||
mail.send(Message(subject="Test Message from re:dash", recipients=[settings.MAIL_DEFAULT_SENDER], body="Test message."))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
try:
|
||||
from playhouse.migrate import Migrator
|
||||
except ImportError:
|
||||
from playhouse.migrate import PostgresqlMigrator as Migrator
|
||||
from playhouse.migrate import PostgresqlMigrator, migrate
|
||||
|
||||
from redash.models import db
|
||||
from redash import models
|
||||
|
||||
if __name__ == '__main__':
|
||||
db.connect_db()
|
||||
migrator = Migrator(db.database)
|
||||
migrator = PostgresqlMigrator(db.database)
|
||||
|
||||
with db.database.transaction():
|
||||
migrator.add_column(models.Query, models.Query.is_archived, 'is_archived')
|
||||
migrate(
|
||||
migrator.add_column('queries', 'is_archived', models.Query.is_archived)
|
||||
)
|
||||
|
||||
db.close_db(None)
|
||||
|
||||
@@ -17,14 +17,24 @@ def update(data_source):
|
||||
for value in values:
|
||||
k, v = value.split("=", 1)
|
||||
configuration[k] = v
|
||||
if k == 'port':
|
||||
configuration[k] = int(v)
|
||||
|
||||
data_source.options = json.dumps(configuration)
|
||||
|
||||
elif data_source.type == 'mysql':
|
||||
mapping = {
|
||||
'Server': 'host',
|
||||
'User': 'user',
|
||||
'Pwd': 'passwd',
|
||||
'Database': 'db'
|
||||
}
|
||||
|
||||
values = data_source.options.split(";")
|
||||
configuration = {}
|
||||
for value in values:
|
||||
k, v = value.split("=", 1)
|
||||
configuration[k] = v
|
||||
configuration[mapping[k]] = v
|
||||
data_source.options = json.dumps(configuration)
|
||||
|
||||
elif data_source.type == 'graphite':
|
||||
|
||||
12
migrations/0004_allow_null_in_event_user.py
Normal file
12
migrations/0004_allow_null_in_event_user.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from playhouse.migrate import PostgresqlMigrator, migrate
|
||||
|
||||
from redash.models import db
|
||||
|
||||
if __name__ == '__main__':
|
||||
db.connect_db()
|
||||
migrator = PostgresqlMigrator(db.database)
|
||||
|
||||
with db.database.transaction():
|
||||
migrate(
|
||||
migrator.drop_not_null('events', 'user_id')
|
||||
)
|
||||
26
migrations/0005_add_updated_at.py
Normal file
26
migrations/0005_add_updated_at.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from playhouse.migrate import PostgresqlMigrator, migrate
|
||||
|
||||
from redash.models import db
|
||||
from redash import models
|
||||
|
||||
if __name__ == '__main__':
|
||||
db.connect_db()
|
||||
migrator = PostgresqlMigrator(db.database)
|
||||
|
||||
with db.database.transaction():
|
||||
migrate(
|
||||
migrator.add_column('queries', 'updated_at', models.Query.updated_at),
|
||||
migrator.add_column('dashboards', 'updated_at', models.Dashboard.updated_at),
|
||||
migrator.add_column('widgets', 'updated_at', models.Widget.updated_at),
|
||||
migrator.add_column('users', 'created_at', models.User.created_at),
|
||||
migrator.add_column('users', 'updated_at', models.User.updated_at),
|
||||
migrator.add_column('visualizations', 'created_at', models.Visualization.created_at),
|
||||
migrator.add_column('visualizations', 'updated_at', models.Visualization.updated_at)
|
||||
)
|
||||
|
||||
db.database.execute_sql("UPDATE queries SET updated_at = created_at;")
|
||||
db.database.execute_sql("UPDATE dashboards SET updated_at = created_at;")
|
||||
db.database.execute_sql("UPDATE widgets SET updated_at = created_at;")
|
||||
|
||||
db.close_db(None)
|
||||
|
||||
19
migrations/0006_queries_last_edit_by.py
Normal file
19
migrations/0006_queries_last_edit_by.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from playhouse.migrate import PostgresqlMigrator, migrate
|
||||
|
||||
from redash.models import db
|
||||
from redash import models
|
||||
|
||||
if __name__ == '__main__':
|
||||
db.connect_db()
|
||||
migrator = PostgresqlMigrator(db.database)
|
||||
|
||||
with db.database.transaction():
|
||||
migrate(
|
||||
migrator.add_column('queries', 'last_modified_by_id', models.Query.last_modified_by)
|
||||
)
|
||||
|
||||
db.database.execute_sql("UPDATE queries SET last_modified_by_id = user_id;")
|
||||
|
||||
db.close_db(None)
|
||||
|
||||
|
||||
23
migrations/0007_add_schedule_to_queries.py
Normal file
23
migrations/0007_add_schedule_to_queries.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from playhouse.migrate import PostgresqlMigrator, migrate
|
||||
|
||||
from redash.models import db
|
||||
from redash import models
|
||||
|
||||
if __name__ == '__main__':
|
||||
db.connect_db()
|
||||
migrator = PostgresqlMigrator(db.database)
|
||||
|
||||
with db.database.transaction():
|
||||
migrate(
|
||||
migrator.add_column('queries', 'schedule', models.Query.schedule),
|
||||
)
|
||||
|
||||
db.database.execute_sql("UPDATE queries SET schedule = ttl WHERE ttl > 0;")
|
||||
|
||||
migrate(
|
||||
migrator.drop_column('queries', 'ttl')
|
||||
)
|
||||
|
||||
db.close_db(None)
|
||||
|
||||
|
||||
20
migrations/0008_make_ds_name_unique.py
Normal file
20
migrations/0008_make_ds_name_unique.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from redash.models import db
|
||||
|
||||
if __name__ == '__main__':
|
||||
db.connect_db()
|
||||
|
||||
with db.database.transaction():
|
||||
# Make sure all data sources names are unique.
|
||||
db.database.execute_sql("""
|
||||
UPDATE data_sources
|
||||
SET name = new_names.name
|
||||
FROM (
|
||||
SELECT id, name || ' ' || id as name
|
||||
FROM (SELECT id, name, rank() OVER (PARTITION BY name ORDER BY created_at ASC) FROM data_sources) ds WHERE rank > 1
|
||||
) AS new_names
|
||||
WHERE data_sources.id = new_names.id;
|
||||
""")
|
||||
# Add unique constraint on data_sources.name.
|
||||
db.database.execute_sql("ALTER TABLE data_sources ADD CONSTRAINT unique_name UNIQUE (name);")
|
||||
|
||||
db.close_db(None)
|
||||
27
migrations/0009_add_api_key_to_user.py
Normal file
27
migrations/0009_add_api_key_to_user.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from playhouse.migrate import PostgresqlMigrator, migrate
|
||||
|
||||
from redash.models import db
|
||||
from redash import models
|
||||
|
||||
if __name__ == '__main__':
|
||||
db.connect_db()
|
||||
migrator = PostgresqlMigrator(db.database)
|
||||
|
||||
with db.database.transaction():
|
||||
column = models.User.api_key
|
||||
column.null = True
|
||||
migrate(
|
||||
migrator.add_column('users', 'api_key', models.User.api_key),
|
||||
)
|
||||
|
||||
for user in models.User.select():
|
||||
user.save()
|
||||
|
||||
migrate(
|
||||
migrator.add_not_null('users', 'api_key')
|
||||
)
|
||||
|
||||
db.close_db(None)
|
||||
|
||||
|
||||
|
||||
18
migrations/0010_allow_deleting_datasources.py
Normal file
18
migrations/0010_allow_deleting_datasources.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from playhouse.migrate import PostgresqlMigrator, migrate
|
||||
|
||||
from redash.models import db
|
||||
|
||||
if __name__ == '__main__':
|
||||
db.connect_db()
|
||||
migrator = PostgresqlMigrator(db.database)
|
||||
|
||||
with db.database.transaction():
|
||||
migrate(
|
||||
migrator.drop_not_null('queries', 'data_source_id'),
|
||||
)
|
||||
|
||||
db.close_db(None)
|
||||
|
||||
|
||||
|
||||
|
||||
8
migrations/0010_create_alerts.py
Normal file
8
migrations/0010_create_alerts.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from redash.models import db, Alert, AlertSubscription
|
||||
|
||||
if __name__ == '__main__':
|
||||
with db.database.transaction():
|
||||
Alert.create_table()
|
||||
AlertSubscription.create_table()
|
||||
|
||||
db.close_db(None)
|
||||
44
migrations/0011_migrate_bigquery_to_json.py
Normal file
44
migrations/0011_migrate_bigquery_to_json.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from base64 import b64encode
|
||||
import json
|
||||
from redash.models import DataSource
|
||||
|
||||
|
||||
def convert_p12_to_pem(p12file):
|
||||
from OpenSSL import crypto
|
||||
with open(p12file, 'rb') as f:
|
||||
p12 = crypto.load_pkcs12(f.read(), "notasecret")
|
||||
|
||||
return crypto.dump_privatekey(crypto.FILETYPE_PEM, p12.get_privatekey())
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
for ds in DataSource.all():
|
||||
|
||||
if ds.type == 'bigquery':
|
||||
options = json.loads(ds.options)
|
||||
|
||||
if 'jsonKeyFile' in options:
|
||||
continue
|
||||
|
||||
new_options = {
|
||||
'projectId': options['projectId'],
|
||||
'jsonKeyFile': b64encode(json.dumps({
|
||||
'client_email': options['serviceAccount'],
|
||||
'private_key': convert_p12_to_pem(options['privateKey'])
|
||||
}))
|
||||
}
|
||||
|
||||
ds.options = json.dumps(new_options)
|
||||
ds.save()
|
||||
elif ds.type == 'google_spreadsheets':
|
||||
options = json.loads(ds.options)
|
||||
if 'jsonKeyFile' in options:
|
||||
continue
|
||||
|
||||
with open(options['credentialsFilePath']) as f:
|
||||
new_options = {
|
||||
'jsonKeyFile': b64encode(f.read())
|
||||
}
|
||||
|
||||
ds.options = json.dumps(new_options)
|
||||
ds.save()
|
||||
6
migrations/0012_add_list_users_permission.py
Normal file
6
migrations/0012_add_list_users_permission.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from redash import models
|
||||
|
||||
if __name__ == '__main__':
|
||||
default_group = models.Group.get(models.Group.name=='default')
|
||||
default_group.permissions.append('list_users')
|
||||
default_group.save()
|
||||
24
migrations/0013_update_counter_options.py
Normal file
24
migrations/0013_update_counter_options.py
Normal file
@@ -0,0 +1,24 @@
|
||||
import json
|
||||
from redash import models
|
||||
|
||||
if __name__ == '__main__':
|
||||
for vis in models.Visualization.select():
|
||||
if vis.type == 'COUNTER':
|
||||
options = json.loads(vis.options)
|
||||
print "Before: ", options
|
||||
if 'rowNumber' in options and options['rowNumber'] is not None:
|
||||
options['rowNumber'] += 1
|
||||
else:
|
||||
options['rowNumber'] = 1
|
||||
|
||||
if 'counterColName' not in options:
|
||||
options['counterColName'] = 'counter'
|
||||
|
||||
if 'targetColName' not in options:
|
||||
options['targetColName'] = 'target'
|
||||
options['targetRowNumber'] = options['rowNumber']
|
||||
|
||||
print "After: ", options
|
||||
vis.options = json.dumps(options)
|
||||
vis.save()
|
||||
|
||||
14
migrations/0014_add_alert_rearm_seconds.py
Normal file
14
migrations/0014_add_alert_rearm_seconds.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from playhouse.migrate import PostgresqlMigrator, migrate
|
||||
|
||||
from redash.models import db
|
||||
from redash import models
|
||||
|
||||
if __name__ == '__main__':
|
||||
db.connect_db()
|
||||
migrator = PostgresqlMigrator(db.database)
|
||||
|
||||
with db.database.transaction():
|
||||
migrate(
|
||||
migrator.add_column('alerts', 'rearm', models.Alert.rearm),
|
||||
)
|
||||
db.close_db(None)
|
||||
10
migrations/0014_migrate_existing_es_to_kibana.py
Normal file
10
migrations/0014_migrate_existing_es_to_kibana.py
Normal file
@@ -0,0 +1,10 @@
|
||||
__author__ = 'lior'
|
||||
|
||||
from redash.models import DataSource
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
for ds in DataSource.all():
|
||||
if ds.type == 'elasticsearch':
|
||||
ds.type = 'kibana'
|
||||
ds.save()
|
||||
6
migrations/0015_add_schedule_query_permission.py
Normal file
6
migrations/0015_add_schedule_query_permission.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from redash import models
|
||||
|
||||
if __name__ == '__main__':
|
||||
default_group = models.Group.get(models.Group.name=='default')
|
||||
default_group.permissions.append('schedule_query')
|
||||
default_group.save()
|
||||
10
migrations/0016_add_alert_subscriber.py
Normal file
10
migrations/0016_add_alert_subscriber.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from redash.models import db, Alert, AlertSubscription
|
||||
|
||||
if __name__ == '__main__':
|
||||
with db.database.transaction():
|
||||
# There was an AWS/GCE image created without this table, to make sure this exists we run this migration.
|
||||
if not AlertSubscription.table_exists():
|
||||
AlertSubscription.create_table()
|
||||
|
||||
db.close_db(None)
|
||||
|
||||
18
migrations/0016_drop_tables_from_group.py
Normal file
18
migrations/0016_drop_tables_from_group.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from playhouse.migrate import PostgresqlMigrator, migrate
|
||||
|
||||
from redash.models import db
|
||||
|
||||
if __name__ == '__main__':
|
||||
db.connect_db()
|
||||
migrator = PostgresqlMigrator(db.database)
|
||||
|
||||
with db.database.transaction():
|
||||
migrate(
|
||||
migrator.drop_column('groups', 'tables')
|
||||
)
|
||||
|
||||
db.close_db(None)
|
||||
|
||||
|
||||
|
||||
|
||||
35
migrations/0017_add_organization.py
Normal file
35
migrations/0017_add_organization.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from redash.models import db, Organization, Group
|
||||
from redash import settings
|
||||
from playhouse.migrate import PostgresqlMigrator, migrate
|
||||
|
||||
if __name__ == '__main__':
|
||||
migrator = PostgresqlMigrator(db.database)
|
||||
|
||||
with db.database.transaction():
|
||||
Organization.create_table()
|
||||
|
||||
default_org = Organization.create(name="Default", slug='default', settings={
|
||||
Organization.SETTING_GOOGLE_APPS_DOMAINS: list(settings.GOOGLE_APPS_DOMAIN)
|
||||
})
|
||||
|
||||
column = Group.org
|
||||
column.default = default_org
|
||||
|
||||
migrate(
|
||||
migrator.add_column('groups', 'org_id', column),
|
||||
migrator.add_column('events', 'org_id', column),
|
||||
migrator.add_column('data_sources', 'org_id', column),
|
||||
migrator.add_column('users', 'org_id', column),
|
||||
migrator.add_column('dashboards', 'org_id', column),
|
||||
migrator.add_column('queries', 'org_id', column),
|
||||
migrator.add_column('query_results', 'org_id', column),
|
||||
)
|
||||
|
||||
# Change the uniqueness constraint on user email to be (org, email):
|
||||
migrate(
|
||||
migrator.drop_index('users', 'users_email'),
|
||||
migrator.add_index('users', ('org_id', 'email'), unique=True)
|
||||
)
|
||||
|
||||
db.close_db(None)
|
||||
|
||||
45
migrations/0018_add_groups_refs.py
Normal file
45
migrations/0018_add_groups_refs.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from collections import defaultdict
|
||||
from redash.models import db, DataSourceGroup, DataSource, Group, Organization, User
|
||||
from playhouse.migrate import PostgresqlMigrator, migrate
|
||||
import peewee
|
||||
|
||||
if __name__ == '__main__':
|
||||
migrator = PostgresqlMigrator(db.database)
|
||||
|
||||
with db.database.transaction():
|
||||
# Add type to groups
|
||||
migrate(
|
||||
migrator.add_column('groups', 'type', Group.type)
|
||||
)
|
||||
|
||||
for name in ['default', 'admin']:
|
||||
group = Group.get(Group.name==name)
|
||||
group.type = Group.BUILTIN_GROUP
|
||||
group.save()
|
||||
|
||||
# Create association table between data sources and groups
|
||||
DataSourceGroup.create_table()
|
||||
|
||||
# add default to existing data source:
|
||||
default_org = Organization.get_by_id(1)
|
||||
default_group = Group.get(Group.name=="default")
|
||||
for ds in DataSource.all(default_org):
|
||||
DataSourceGroup.create(data_source=ds, group=default_group)
|
||||
|
||||
# change the groups list on a user object to be an ids list
|
||||
migrate(
|
||||
migrator.rename_column('users', 'groups', 'old_groups'),
|
||||
)
|
||||
|
||||
migrate(migrator.add_column('users', 'groups', User.groups))
|
||||
|
||||
group_map = dict(map(lambda g: (g.name, g.id), Group.select()))
|
||||
user_map = defaultdict(list)
|
||||
for user in User.select(User, peewee.SQL('old_groups')):
|
||||
group_ids = [group_map[group] for group in user.old_groups]
|
||||
user.update_instance(groups=group_ids)
|
||||
|
||||
migrate(migrator.drop_column('users', 'old_groups'))
|
||||
|
||||
db.close_db(None)
|
||||
|
||||
6
migrations/0019_add_super_admin_permission.py
Normal file
6
migrations/0019_add_super_admin_permission.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from redash import models
|
||||
|
||||
if __name__ == '__main__':
|
||||
admin_group = models.Group.get(models.Group.name=='admin')
|
||||
admin_group.permissions.append('super_admin')
|
||||
admin_group.save()
|
||||
19
migrations/0020_change_ds_name_to_non_uniqe.py
Normal file
19
migrations/0020_change_ds_name_to_non_uniqe.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from redash.models import db
|
||||
import peewee
|
||||
from playhouse.migrate import PostgresqlMigrator, migrate
|
||||
|
||||
if __name__ == '__main__':
|
||||
migrator = PostgresqlMigrator(db.database)
|
||||
|
||||
with db.database.transaction():
|
||||
# Change the uniqueness constraint on data source name to be (org, name):
|
||||
# In some cases it's a constraint:
|
||||
db.database.execute_sql('ALTER TABLE data_sources DROP CONSTRAINT IF EXISTS unique_name')
|
||||
# In others only an index:
|
||||
db.database.execute_sql('DROP INDEX IF EXISTS data_sources_name')
|
||||
|
||||
migrate(
|
||||
migrator.add_index('data_sources', ('org_id', 'name'), unique=True)
|
||||
)
|
||||
|
||||
db.close_db(None)
|
||||
@@ -1,13 +0,0 @@
|
||||
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)
|
||||
@@ -1,12 +0,0 @@
|
||||
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)
|
||||
@@ -1,12 +0,0 @@
|
||||
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)
|
||||
@@ -1,13 +0,0 @@
|
||||
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)
|
||||
@@ -1,13 +0,0 @@
|
||||
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)
|
||||
@@ -1,13 +0,0 @@
|
||||
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)
|
||||
@@ -1,13 +0,0 @@
|
||||
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)
|
||||
@@ -1,12 +0,0 @@
|
||||
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)
|
||||
@@ -1,13 +0,0 @@
|
||||
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)
|
||||
@@ -1,11 +0,0 @@
|
||||
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)
|
||||
@@ -1,48 +0,0 @@
|
||||
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)
|
||||
@@ -1,12 +0,0 @@
|
||||
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)
|
||||
@@ -1,56 +0,0 @@
|
||||
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)
|
||||
@@ -1,70 +0,0 @@
|
||||
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)
|
||||
@@ -1,29 +0,0 @@
|
||||
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)
|
||||
@@ -19,6 +19,7 @@
|
||||
"trailing": true,
|
||||
"smarttabs": true,
|
||||
"globals": {
|
||||
"angular": false
|
||||
"angular": false,
|
||||
"_": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,7 +186,7 @@ module.exports = function (grunt) {
|
||||
// concat, minify and revision files. Creates configurations in memory so
|
||||
// additional tasks can operate on them
|
||||
useminPrepare: {
|
||||
html: ['<%= yeoman.app %>/index.html', '<%= yeoman.app %>/login.html'],
|
||||
html: ['<%= yeoman.app %>/index.html', '<%= yeoman.app %>/login.html', '<%= yeoman.app %>/embed.html'],
|
||||
options: {
|
||||
dest: '<%= yeoman.dist %>',
|
||||
flow: {
|
||||
@@ -236,17 +236,6 @@ module.exports = function (grunt) {
|
||||
// dist: {}
|
||||
// },
|
||||
|
||||
imagemin: {
|
||||
dist: {
|
||||
files: [{
|
||||
expand: true,
|
||||
cwd: '<%= yeoman.app %>/images',
|
||||
src: '{,*/}*.{png,jpg,jpeg,gif}',
|
||||
dest: '<%= yeoman.dist %>/images'
|
||||
}]
|
||||
}
|
||||
},
|
||||
|
||||
svgmin: {
|
||||
dist: {
|
||||
files: [{
|
||||
@@ -313,6 +302,11 @@ module.exports = function (grunt) {
|
||||
'images/{,*/}*.{webp}',
|
||||
'fonts/*'
|
||||
]
|
||||
}, {
|
||||
expand: true,
|
||||
cwd: '<%= yeoman.app %>/images',
|
||||
dest: '<%= yeoman.dist %>/images',
|
||||
src: ['*']
|
||||
}, {
|
||||
expand: true,
|
||||
cwd: '.tmp/images',
|
||||
@@ -348,7 +342,6 @@ module.exports = function (grunt) {
|
||||
],
|
||||
dist: [
|
||||
'copy:styles',
|
||||
'imagemin',
|
||||
'svgmin'
|
||||
]
|
||||
},
|
||||
|
||||
127
rd_ui/app/embed.html
Normal file
127
rd_ui/app/embed.html
Normal file
@@ -0,0 +1,127 @@
|
||||
<!DOCTYPE html>
|
||||
<!--[if lt IE 7]> <html class="no-js lt-ie9 lt-ie8 lt-ie7" ng-app="redash" ng-controller='MainCtrl'> <![endif]-->
|
||||
<!--[if IE 7]> <html class="no-js lt-ie9 lt-ie8" ng-app="redash" ng-controller='MainCtrl'> <![endif]-->
|
||||
<!--[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='EmbedCtrl'> <!--<![endif]-->
|
||||
<head>
|
||||
<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">
|
||||
|
||||
<!-- build:css /styles/embed.css -->
|
||||
<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/dist/pivot.css">
|
||||
<link rel="stylesheet" href="/bower_components/cornelius/src/cornelius.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="/bower_components/font-awesome/css/font-awesome.css">
|
||||
<link rel="stylesheet" href="/bower_components/codemirror/addon/hint/show-hint.css">
|
||||
<link rel="stylesheet" href="/bower_components/leaflet/dist/leaflet.css">
|
||||
<link rel="stylesheet" href="/styles/redash.css">
|
||||
<!-- endbuild -->
|
||||
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/images/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="/images/favicon-96x96.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/images/favicon-16x16.png">
|
||||
|
||||
<style>
|
||||
body { padding:0; }
|
||||
.col-lg-12, .row, .container, .panel { margin:0; padding:0; }
|
||||
.container::after, .row::after { display:none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div growl></div>
|
||||
<div ng-view></div>
|
||||
|
||||
<script src="/bower_components/jquery/jquery.js"></script>
|
||||
|
||||
<!-- build:js /scripts/embed-plugins.js -->
|
||||
<script src="/bower_components/angular/angular.js"></script>
|
||||
<script src="/bower_components/angular-sanitize/angular-sanitize.js"></script>
|
||||
<script src="/bower_components/jquery-ui/ui/jquery-ui.js"></script>
|
||||
<script src="/bower_components/bootstrap/js/collapse.js"></script>
|
||||
<script src="/bower_components/bootstrap/js/modal.js"></script>
|
||||
<script src="/bower_components/angular-resource/angular-resource.js"></script>
|
||||
<script src="/bower_components/angular-route/angular-route.js"></script>
|
||||
<script src="/bower_components/underscore/underscore.js"></script>
|
||||
<script src="/bower_components/moment/moment.js"></script>
|
||||
<script src="/bower_components/angular-moment/angular-moment.js"></script>
|
||||
<script src="/bower_components/codemirror/lib/codemirror.js"></script>
|
||||
<script src="/bower_components/codemirror/addon/edit/matchbrackets.js"></script>
|
||||
<script src="/bower_components/codemirror/addon/edit/closebrackets.js"></script>
|
||||
<script src="/bower_components/codemirror/addon/hint/show-hint.js"></script>
|
||||
<script src="/bower_components/codemirror/addon/hint/anyword-hint.js"></script>
|
||||
<script src="/bower_components/codemirror/mode/sql/sql.js"></script>
|
||||
<script src="/bower_components/codemirror/mode/python/python.js"></script>
|
||||
<script src="/bower_components/codemirror/mode/javascript/javascript.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/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/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="/bower_components/angular-base64-upload/dist/angular-base64-upload.js"></script>
|
||||
<script src="/bower_components/plotly/plotly.js"></script>
|
||||
<script src="/bower_components/angular-plotly/src/angular-plotly.js"></script>
|
||||
<script src="/scripts/directives/plotly.js"></script>
|
||||
<script src="/scripts/ng_smart_table.js"></script>
|
||||
<script src="/bower_components/angular-ui-bootstrap-bower/ui-bootstrap-tpls.js"></script>
|
||||
<script src="/bower_components/bucky/bucky.js"></script>
|
||||
<script src="/bower_components/pace/pace.js"></script>
|
||||
<script src="/bower_components/mustache/mustache.js"></script>
|
||||
<script src="/bower_components/canvg/rgbcolor.js"></script>
|
||||
<script src="/bower_components/canvg/StackBlur.js"></script>
|
||||
<script src="/bower_components/canvg/canvg.js"></script>
|
||||
<script src="/bower_components/leaflet/dist/leaflet.js"></script>
|
||||
<script src="/bower_components/angular-bootstrap-show-errors/src/showErrors.js"></script>
|
||||
<script src="/bower_components/d3/d3.min.js"></script>
|
||||
<script src="/bower_components/angular-ui-sortable/sortable.js"></script>
|
||||
<!-- endbuild -->
|
||||
|
||||
<!-- build:js({.tmp,app}) /scripts/embed-scripts.js -->
|
||||
<script src="/scripts/embed.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/controllers/controllers.js"></script>
|
||||
<script src="/scripts/controllers/dashboard.js"></script>
|
||||
<script src="/scripts/controllers/admin_controllers.js"></script>
|
||||
<script src="/scripts/controllers/data_sources.js"></script>
|
||||
<script src="/scripts/controllers/query_view.js"></script>
|
||||
<script src="/scripts/controllers/query_source.js"></script>
|
||||
<script src="/scripts/controllers/users.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/map.js"></script>
|
||||
<script src="/scripts/visualizations/counter.js"></script>
|
||||
<script src="/scripts/visualizations/boxplot.js"></script>
|
||||
<script src="/scripts/visualizations/box.js"></script>
|
||||
<script src="/scripts/visualizations/table.js"></script>
|
||||
<script src="/scripts/visualizations/pivot.js"></script>
|
||||
<script src="/scripts/visualizations/date_range_selector.js"></script>
|
||||
<script src="/scripts/directives/directives.js"></script>
|
||||
<script src="/scripts/directives/query_directives.js"></script>
|
||||
<script src="/scripts/directives/data_source_directives.js"></script>
|
||||
<script src="/scripts/directives/dashboard_directives.js"></script>
|
||||
<script src="/scripts/filters.js"></script>
|
||||
<script src="/scripts/controllers/alerts.js"></script>
|
||||
<!-- endbuild -->
|
||||
|
||||
<script>
|
||||
var clientConfig = {{ client_config|safe }};
|
||||
var visualization = {{ visualization|safe }};
|
||||
var query_result = {{ query_result|safe }};
|
||||
|
||||
{{ analytics|safe }}
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
BIN
rd_ui/app/images/favicon-16x16.png
Executable file
BIN
rd_ui/app/images/favicon-16x16.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
BIN
rd_ui/app/images/favicon-32x32.png
Executable file
BIN
rd_ui/app/images/favicon-32x32.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
BIN
rd_ui/app/images/favicon-96x96.png
Executable file
BIN
rd_ui/app/images/favicon-96x96.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
BIN
rd_ui/app/images/redash_icon_small.png
Normal file
BIN
rd_ui/app/images/redash_icon_small.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.0 KiB |
@@ -4,6 +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>
|
||||
<base href="{{base_href}}">
|
||||
<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">
|
||||
@@ -14,12 +15,18 @@
|
||||
<link rel="stylesheet" href="/bower_components/gridster/dist/jquery.gridster.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="/bower_components/font-awesome/css/font-awesome.css">
|
||||
<link rel="stylesheet" href="/bower_components/codemirror/addon/hint/show-hint.css">
|
||||
<link rel="stylesheet" href="/bower_components/leaflet/dist/leaflet.css">
|
||||
<link rel="stylesheet" href="/styles/redash.css">
|
||||
<!-- endbuild -->
|
||||
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/images/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="/images/favicon-96x96.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/images/favicon-16x16.png">
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<div growl></div>
|
||||
@@ -33,39 +40,41 @@
|
||||
<span class="icon-bar"></span>
|
||||
<span class="icon-bar"></span>
|
||||
</button>
|
||||
<a class="navbar-brand" href="/"><strong>{{name}}</strong></a>
|
||||
<a class="navbar-brand" href="{{base_href}}"><img src="/images/redash_icon_small.png"/></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" 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">
|
||||
<li class="dropdown" ng-show="groupedDashboards.length > 0 || otherDashboards.length > 0 || currentUser.hasPermission('create_dashboard')" dropdown>
|
||||
<a href="#" class="dropdown-toggle" dropdown-toggle><span class="fa fa-tachometer"></span> <b class="caret"></b></a>
|
||||
<ul class="dropdown-menu" dropdown-menu>
|
||||
<span ng-repeat="(name, group) in groupedDashboards">
|
||||
<li class="dropdown-submenu">
|
||||
<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" 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" 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 ng-show="currentUser.hasPermission('create_query')"><a href="/queries/new">New Query</a></li>
|
||||
<li><a href="/queries">Queries</a></li>
|
||||
<li class="dropdown" ng-show="currentUser.hasPermission('view_query')" dropdown>
|
||||
<a href="#" class="dropdown-toggle" dropdown-toggle>Queries <b class="caret"></b></a>
|
||||
<ul class="dropdown-menu" dropdown-menu>
|
||||
<li ng-show="currentUser.hasPermission('create_query')"><a href="queries/new">New Query</a></li>
|
||||
<li><a href="queries">Queries</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<a href="alerts">Alerts</a>
|
||||
</li>
|
||||
</ul>
|
||||
<form class="navbar-form navbar-left" role="search" ng-submit="searchQueries()">
|
||||
<div class="form-group">
|
||||
@@ -74,12 +83,34 @@
|
||||
<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" 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>
|
||||
<li ng-show="currentUser.hasPermission('admin')">
|
||||
<a href="data_sources" title="Data Sources"><i class="fa fa-database"></i></a>
|
||||
</li>
|
||||
<li ng-show="currentUser.hasPermission('list_users')">
|
||||
<a href="users" title="Users"><i class="fa fa-users"></i></a>
|
||||
</li>
|
||||
<li class="dropdown" dropdown>
|
||||
<a href="#" class="dropdown-toggle" dropdown-toggle><span ng-bind="currentUser.name"></span> <span class="caret"></span></a>
|
||||
<ul class="dropdown-menu" dropdown-menu>
|
||||
<li style="width:300px">
|
||||
<a ng-href="users/{{currentUser.id}}">
|
||||
<div class="row">
|
||||
<div class="col-sm-2">
|
||||
<img ng-src="{{currentUser.gravatar_url}}" size="40px" class="img-circle"/>
|
||||
</div>
|
||||
<div class="col-sm-10">
|
||||
<p><strong>{{currentUser.name}}</strong></p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
<li class="divider">
|
||||
</li>
|
||||
<li>
|
||||
<a href="logout" target="_self">Log out</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endraw %}
|
||||
@@ -89,11 +120,38 @@
|
||||
|
||||
<edit-dashboard-form dashboard="newDashboard" id="new_dashboard_dialog"></edit-dashboard-form>
|
||||
<div ng-view></div>
|
||||
<div ng-if="showPermissionError" class="ng-cloak container" ng-cloak>
|
||||
<div class="row">
|
||||
<div class="text-center">
|
||||
<h1><span class="glyphicon glyphicon-lock"></span></h1>
|
||||
<p class="text-muted">
|
||||
You do not have permission to view the requested page.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% raw %}
|
||||
<div class="container-fluid footer">
|
||||
<hr/>
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<a href="http://redash.io">re:dash</a> <span ng-bind="version"></span>
|
||||
<small ng-if="newVersionAvailable" ng-cloak class="ng-cloak"><a href="http://version.redash.io/">(new re:dash version available)</a></small>
|
||||
<div class="pull-right">
|
||||
<a href="http://docs.redash.io/">Docs</a>
|
||||
<a href="http://github.com/getredash/redash">Contribute</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endraw %}
|
||||
|
||||
<script src="/bower_components/jquery/jquery.js"></script>
|
||||
|
||||
<!-- build:js /scripts/plugins.js -->
|
||||
<script src="/bower_components/angular/angular.js"></script>
|
||||
<script src="/bower_components/angular-sanitize/angular-sanitize.js"></script>
|
||||
<script src="/bower_components/jquery-ui/ui/jquery-ui.js"></script>
|
||||
<script src="/bower_components/bootstrap/js/collapse.js"></script>
|
||||
<script src="/bower_components/bootstrap/js/modal.js"></script>
|
||||
@@ -105,31 +163,35 @@
|
||||
<script src="/bower_components/codemirror/lib/codemirror.js"></script>
|
||||
<script src="/bower_components/codemirror/addon/edit/matchbrackets.js"></script>
|
||||
<script src="/bower_components/codemirror/addon/edit/closebrackets.js"></script>
|
||||
<script src="/bower_components/codemirror/addon/hint/show-hint.js"></script>
|
||||
<script src="/bower_components/codemirror/addon/hint/anyword-hint.js"></script>
|
||||
<script src="/bower_components/codemirror/mode/sql/sql.js"></script>
|
||||
<script src="/bower_components/codemirror/mode/python/python.js"></script>
|
||||
<script src="/bower_components/codemirror/mode/javascript/javascript.js"></script>
|
||||
<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="/bower_components/gridster/dist/jquery.gridster.js"></script>
|
||||
<script src="/bower_components/angular-growl/build/angular-growl.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="/bower_components/angular-base64-upload/dist/angular-base64-upload.js"></script>
|
||||
<script src="/bower_components/plotly/plotly.js"></script>
|
||||
<script src="/bower_components/angular-plotly/src/angular-plotly.js"></script>
|
||||
<script src="/scripts/directives/plotly.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/angular-ui-bootstrap-bower/ui-bootstrap-tpls.js"></script>
|
||||
<script src="/bower_components/pace/pace.js"></script>
|
||||
<script src="/bower_components/mustache/mustache.js"></script>
|
||||
<script src="/bower_components/canvg/rgbcolor.js"></script>
|
||||
<script src="/bower_components/canvg/rgbcolor.js"></script>
|
||||
<script src="/bower_components/canvg/StackBlur.js"></script>
|
||||
<script src="/bower_components/canvg/canvg.js"></script>
|
||||
<script src="/bower_components/canvg/canvg.js"></script>
|
||||
<script src="/bower_components/leaflet/dist/leaflet.js"></script>
|
||||
<script src="/bower_components/angular-bootstrap-show-errors/src/showErrors.js"></script>
|
||||
<script src="/bower_components/d3/d3.min.js"></script>
|
||||
<script src="/bower_components/angular-ui-sortable/sortable.js"></script>
|
||||
<!-- endbuild -->
|
||||
|
||||
<!-- build:js({.tmp,app}) /scripts/scripts.js -->
|
||||
@@ -141,33 +203,45 @@
|
||||
<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/data_sources.js"></script>
|
||||
<script src="/scripts/controllers/query_view.js"></script>
|
||||
<script src="/scripts/controllers/query_source.js"></script>
|
||||
<script src="/scripts/controllers/users.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/map.js"></script>
|
||||
<script src="/scripts/visualizations/counter.js"></script>
|
||||
<script src="/scripts/visualizations/boxplot.js"></script>
|
||||
<script src="/scripts/visualizations/box.js"></script>
|
||||
<script src="/scripts/visualizations/table.js"></script>
|
||||
<script src="/scripts/visualizations/pivot.js"></script>
|
||||
<script src="/scripts/visualizations/date_range_selector.js"></script>
|
||||
<script src="/scripts/directives/directives.js"></script>
|
||||
<script src="/scripts/directives/query_directives.js"></script>
|
||||
<script src="/scripts/directives/data_source_directives.js"></script>
|
||||
<script src="/scripts/directives/dashboard_directives.js"></script>
|
||||
<script src="/scripts/filters.js"></script>
|
||||
<script src="/scripts/controllers/alerts.js"></script>
|
||||
<!-- endbuild -->
|
||||
|
||||
<script>
|
||||
// TODO: move currentUser & features to be an Angular service
|
||||
var featureFlags = {{ features|safe }};
|
||||
var clientConfig = {{ client_config|safe }};
|
||||
var currentUser = {{ user|safe }};
|
||||
var currentOrgSlug = "{{ org_slug }}";
|
||||
|
||||
currentUser.canEdit = function(object) {
|
||||
var user_id = object.user_id || (object.user && object.user.id);
|
||||
return user_id && (user_id == currentUser.id);
|
||||
return this.hasPermission('admin') || (user_id && (user_id == currentUser.id));
|
||||
};
|
||||
|
||||
currentUser.hasPermission = function(permission) {
|
||||
return this.permissions.indexOf(permission) != -1;
|
||||
}
|
||||
};
|
||||
|
||||
currentUser.isAdmin = currentUser.hasPermission('admin');
|
||||
|
||||
|
||||
{{ analytics|safe }}
|
||||
</script>
|
||||
|
||||
@@ -13,6 +13,10 @@
|
||||
<link rel="stylesheet" href="/styles/redash.css">
|
||||
<link rel="stylesheet" href="/styles/login.css">
|
||||
<!-- endbuild -->
|
||||
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/images/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="/images/favicon-96x96.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/images/favicon-16x16.png">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -26,19 +30,39 @@
|
||||
<span class="icon-bar"></span>
|
||||
<span class="icon-bar"></span>
|
||||
</button>
|
||||
<a class="navbar-brand" href="/"><strong>{{name}}</strong></a>
|
||||
<a class="navbar-brand" href="/"><img src="/images/redash_icon_small.png"/></a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
{% with messages = get_flashed_messages() %}
|
||||
{% if messages %}
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-warning" role="alert">{{ message }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<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>
|
||||
<a href="{{ google_auth_url }}"><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 %}
|
||||
|
||||
{% if show_saml_login %}
|
||||
|
||||
<div class="row">
|
||||
<a href="/saml/login">SAML Login</a>
|
||||
</div>
|
||||
|
||||
<div class="login-or">
|
||||
@@ -50,8 +74,8 @@
|
||||
|
||||
<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}}">
|
||||
<label for="inputEmail">Email</label>
|
||||
<input type="text" class="form-control" id="inputEmail" name="email" value="{{email}}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<!--<a class="pull-right" href="#">Forgot password?</a>-->
|
||||
|
||||
@@ -6,32 +6,28 @@ angular.module('redash', [
|
||||
'redash.services',
|
||||
'redash.renderers',
|
||||
'redash.visualization',
|
||||
'ui.codemirror',
|
||||
'highchart',
|
||||
'ui.select2',
|
||||
'plotly',
|
||||
'plotly-chart',
|
||||
'angular-growl',
|
||||
'angularMoment',
|
||||
'ui.bootstrap',
|
||||
'ui.sortable',
|
||||
'smartTable.table',
|
||||
'ngResource',
|
||||
'ngRoute',
|
||||
'ui.select'
|
||||
]).config(['$routeProvider', '$locationProvider', '$compileProvider', 'growlProvider',
|
||||
function ($routeProvider, $locationProvider, $compileProvider, growlProvider) {
|
||||
if (featureFlags.clientSideMetrics) {
|
||||
Bucky.setOptions({
|
||||
host: '/api/metrics'
|
||||
});
|
||||
|
||||
Bucky.requests.monitor('ajax_requsts');
|
||||
Bucky.requests.transforms.enable('dashboards', /dashboard\/[\w-]+/ig, '/dashboard');
|
||||
}
|
||||
|
||||
'ui.select',
|
||||
'naif.base64',
|
||||
'ui.bootstrap.showErrors',
|
||||
'ngSanitize'
|
||||
]).config(['$routeProvider', '$locationProvider', '$compileProvider', 'growlProvider', 'uiSelectConfig',
|
||||
function ($routeProvider, $locationProvider, $compileProvider, growlProvider, uiSelectConfig) {
|
||||
function getQuery(Query, $route) {
|
||||
var query = Query.get({'id': $route.current.params.queryId });
|
||||
return query.$promise;
|
||||
};
|
||||
|
||||
uiSelectConfig.theme = "bootstrap";
|
||||
|
||||
$compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|http|data):/);
|
||||
$locationProvider.html5Mode(true);
|
||||
growlProvider.globalTimeToLive(2000);
|
||||
@@ -53,7 +49,8 @@ angular.module('redash', [
|
||||
resolve: {
|
||||
'query': ['Query', function newQuery(Query) {
|
||||
return Query.newQuery();
|
||||
}]
|
||||
}],
|
||||
'dataSources': ['DataSource', function (DataSource) { return DataSource.query().$promise }]
|
||||
}
|
||||
});
|
||||
$routeProvider.when('/queries/search', {
|
||||
@@ -81,18 +78,56 @@ angular.module('redash', [
|
||||
templateUrl: '/views/admin_status.html',
|
||||
controller: 'AdminStatusCtrl'
|
||||
});
|
||||
$routeProvider.when('/admin/workers', {
|
||||
templateUrl: '/views/admin_workers.html',
|
||||
controller: 'AdminWorkersCtrl'
|
||||
|
||||
$routeProvider.when('/alerts', {
|
||||
templateUrl: '/views/alerts/list.html',
|
||||
controller: 'AlertsCtrl'
|
||||
});
|
||||
$routeProvider.when('/alerts/:alertId', {
|
||||
templateUrl: '/views/alerts/edit.html',
|
||||
controller: 'AlertCtrl'
|
||||
});
|
||||
|
||||
$routeProvider.when('/data_sources/:dataSourceId', {
|
||||
templateUrl: '/views/data_sources/edit.html',
|
||||
controller: 'DataSourceCtrl'
|
||||
});
|
||||
$routeProvider.when('/data_sources', {
|
||||
templateUrl: '/views/data_sources/list.html',
|
||||
controller: 'DataSourcesCtrl'
|
||||
});
|
||||
|
||||
$routeProvider.when('/users/new', {
|
||||
templateUrl: '/views/users/new.html',
|
||||
controller: 'NewUserCtrl'
|
||||
});
|
||||
$routeProvider.when('/users/:userId', {
|
||||
templateUrl: '/views/users/show.html',
|
||||
reloadOnSearch: false,
|
||||
controller: 'UserCtrl'
|
||||
});
|
||||
$routeProvider.when('/users', {
|
||||
templateUrl: '/views/users/list.html',
|
||||
controller: 'UsersCtrl'
|
||||
});
|
||||
$routeProvider.when('/groups/:groupId/data_sources', {
|
||||
templateUrl: '/views/groups/show_data_sources.html',
|
||||
controller: 'GroupDataSourcesCtrl'
|
||||
});
|
||||
$routeProvider.when('/groups/:groupId', {
|
||||
templateUrl: '/views/groups/show.html',
|
||||
controller: 'GroupCtrl'
|
||||
});
|
||||
$routeProvider.when('/groups', {
|
||||
templateUrl: '/views/groups/list.html',
|
||||
controller: 'GroupsCtrl'
|
||||
})
|
||||
$routeProvider.when('/', {
|
||||
templateUrl: '/views/index.html',
|
||||
controller: 'IndexCtrl'
|
||||
});
|
||||
$routeProvider.when('/personal', {
|
||||
templateUrl: '/views/personal.html',
|
||||
controller: 'PersonalIndexCtrl'
|
||||
redirectTo: '/'
|
||||
});
|
||||
$routeProvider.otherwise({
|
||||
redirectTo: '/'
|
||||
|
||||
@@ -16,16 +16,9 @@
|
||||
$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])
|
||||
})();
|
||||
|
||||
176
rd_ui/app/scripts/controllers/alerts.js
Normal file
176
rd_ui/app/scripts/controllers/alerts.js
Normal file
@@ -0,0 +1,176 @@
|
||||
(function() {
|
||||
|
||||
var AlertsCtrl = function($scope, Events, Alert) {
|
||||
Events.record(currentUser, "view", "page", "alerts");
|
||||
$scope.$parent.pageTitle = "Alerts";
|
||||
|
||||
$scope.alerts = []
|
||||
Alert.query(function(alerts) {
|
||||
var stateClass = {
|
||||
'ok': 'label label-success',
|
||||
'triggered': 'label label-danger',
|
||||
'unknown': 'label label-warning'
|
||||
};
|
||||
_.each(alerts, function(alert) {
|
||||
alert.class = stateClass[alert.state];
|
||||
})
|
||||
$scope.alerts = alerts;
|
||||
|
||||
});
|
||||
|
||||
$scope.gridConfig = {
|
||||
isPaginationEnabled: true,
|
||||
itemsByPage: 50,
|
||||
maxSize: 8,
|
||||
};
|
||||
|
||||
|
||||
$scope.gridColumns = [
|
||||
{
|
||||
"label": "Name",
|
||||
"map": "name",
|
||||
"cellTemplate": '<a href="alerts/{{dataRow.id}}">{{dataRow.name}}</a> (<a href="queries/{{dataRow.query.id}}">query</a>)'
|
||||
},
|
||||
{
|
||||
'label': 'Created By',
|
||||
'map': 'user.name'
|
||||
},
|
||||
{
|
||||
'label': 'State',
|
||||
'cellTemplate': '<span ng-class="dataRow.class">{{dataRow.state | uppercase}}</span> since <span am-time-ago="dataRow.updated_at"></span>'
|
||||
},
|
||||
{
|
||||
'label': 'Created At',
|
||||
'cellTemplate': '<span am-time-ago="dataRow.created_at"></span>'
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
var AlertCtrl = function($scope, $routeParams, $location, growl, Query, Events, Alert) {
|
||||
$scope.$parent.pageTitle = "Alerts";
|
||||
|
||||
$scope.alertId = $routeParams.alertId;
|
||||
if ($scope.alertId === "new") {
|
||||
Events.record(currentUser, 'view', 'page', 'alerts/new');
|
||||
} else {
|
||||
Events.record(currentUser, 'view', 'alert', $scope.alertId);
|
||||
}
|
||||
|
||||
$scope.onQuerySelected = function(item) {
|
||||
$scope.selectedQuery = item;
|
||||
item.getQueryResultPromise().then(function(result) {
|
||||
$scope.queryResult = result;
|
||||
$scope.alert.options.column = $scope.alert.options.column || result.getColumnNames()[0];
|
||||
});
|
||||
};
|
||||
|
||||
if ($scope.alertId === "new") {
|
||||
$scope.alert = new Alert({options: {}});
|
||||
} else {
|
||||
$scope.alert = Alert.get({id: $scope.alertId}, function(alert) {
|
||||
$scope.onQuerySelected(new Query($scope.alert.query));
|
||||
});
|
||||
}
|
||||
|
||||
$scope.ops = ['greater than', 'less than', 'equals'];
|
||||
$scope.selectedQuery = null;
|
||||
|
||||
$scope.getDefaultName = function() {
|
||||
if (!$scope.alert.query) {
|
||||
return undefined;
|
||||
}
|
||||
return _.template("<%= query.name %>: <%= options.column %> <%= options.op %> <%= options.value %>", $scope.alert);
|
||||
};
|
||||
|
||||
$scope.searchQueries = function (term) {
|
||||
if (!term || term.length < 3) {
|
||||
return;
|
||||
}
|
||||
|
||||
Query.search({q: term}, function(results) {
|
||||
$scope.queries = results;
|
||||
});
|
||||
};
|
||||
|
||||
$scope.saveChanges = function() {
|
||||
if ($scope.alert.name === undefined || $scope.alert.name === '') {
|
||||
$scope.alert.name = $scope.getDefaultName();
|
||||
}
|
||||
if ($scope.alert.rearm === '' || $scope.alert.rearm === 0) {
|
||||
$scope.alert.rearm = null;
|
||||
}
|
||||
$scope.alert.$save(function(alert) {
|
||||
growl.addSuccessMessage("Saved.");
|
||||
if ($scope.alertId === "new") {
|
||||
$location.path('/alerts/' + alert.id).replace();
|
||||
}
|
||||
}, function() {
|
||||
growl.addErrorMessage("Failed saving alert.");
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
angular.module('redash.directives').directive('alertSubscribers', ['AlertSubscription', function (AlertSubscription) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
replace: true,
|
||||
templateUrl: '/views/alerts/subscribers.html',
|
||||
scope: {
|
||||
'alertId': '='
|
||||
},
|
||||
controller: function ($scope) {
|
||||
$scope.subscribers = AlertSubscription.query({alertId: $scope.alertId});
|
||||
}
|
||||
}
|
||||
}]);
|
||||
|
||||
angular.module('redash.directives').directive('subscribeButton', ['AlertSubscription', 'growl', function (AlertSubscription, growl) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
replace: true,
|
||||
template: '<button class="btn btn-default btn-xs" ng-click="toggleSubscription()"><i ng-class="class"></i></button>',
|
||||
controller: function ($scope) {
|
||||
var updateClass = function() {
|
||||
if ($scope.subscription) {
|
||||
$scope.class = "fa fa-eye-slash";
|
||||
} else {
|
||||
$scope.class = "fa fa-eye";
|
||||
}
|
||||
}
|
||||
|
||||
$scope.subscribers.$promise.then(function() {
|
||||
$scope.subscription = _.find($scope.subscribers, function(subscription) {
|
||||
return (subscription.user.email == currentUser.email);
|
||||
});
|
||||
|
||||
updateClass();
|
||||
});
|
||||
|
||||
$scope.toggleSubscription = function() {
|
||||
if ($scope.subscription) {
|
||||
$scope.subscription.$delete(function() {
|
||||
$scope.subscribers = _.without($scope.subscribers, $scope.subscription);
|
||||
$scope.subscription = undefined;
|
||||
updateClass();
|
||||
}, function() {
|
||||
growl.addErrorMessage("Failed saving subscription.");
|
||||
});
|
||||
} else {
|
||||
$scope.subscription = new AlertSubscription({alert_id: $scope.alertId});
|
||||
$scope.subscription.$save(function() {
|
||||
$scope.subscribers.push($scope.subscription);
|
||||
updateClass();
|
||||
}, function() {
|
||||
growl.addErrorMessage("Unsubscription failed.");
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}]);
|
||||
|
||||
angular.module('redash.controllers')
|
||||
.controller('AlertsCtrl', ['$scope', 'Events', 'Alert', AlertsCtrl])
|
||||
.controller('AlertCtrl', ['$scope', '$routeParams', '$location', 'growl', 'Query', 'Events', 'Alert', AlertCtrl])
|
||||
|
||||
})();
|
||||
@@ -1,4 +1,12 @@
|
||||
(function () {
|
||||
var dateFormatter = function (value) {
|
||||
if (!value) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
return value.format(clientConfig.dateTimeFormat);
|
||||
};
|
||||
|
||||
var QuerySearchCtrl = function($scope, $location, $filter, Events, Query) {
|
||||
$scope.$parent.pageTitle = "Queries Search";
|
||||
|
||||
@@ -8,11 +16,6 @@
|
||||
maxSize: 8,
|
||||
};
|
||||
|
||||
var dateFormatter = function (value) {
|
||||
if (!value) return "-";
|
||||
return value.format("DD/MM/YY HH:mm");
|
||||
}
|
||||
|
||||
$scope.gridColumns = [
|
||||
{
|
||||
"label": "Name",
|
||||
@@ -30,9 +33,9 @@
|
||||
},
|
||||
{
|
||||
'label': 'Update Schedule',
|
||||
'map': 'ttl',
|
||||
'map': 'schedule',
|
||||
'formatFunction': function (value) {
|
||||
return $filter('refreshRateHumanize')(value);
|
||||
return $filter('scheduleHumanize')(value);
|
||||
}
|
||||
}
|
||||
];
|
||||
@@ -70,11 +73,6 @@
|
||||
$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) {
|
||||
@@ -130,9 +128,9 @@
|
||||
},
|
||||
{
|
||||
'label': 'Update Schedule',
|
||||
'map': 'ttl',
|
||||
'map': 'schedule',
|
||||
'formatFunction': function (value) {
|
||||
return $filter('refreshRateHumanize')(value);
|
||||
return $filter('scheduleHumanize')(value);
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -153,13 +151,20 @@
|
||||
}
|
||||
|
||||
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.$on("$routeChangeSuccess", function (event, current, previous, rejection) {
|
||||
if ($scope.showPermissionError) {
|
||||
$scope.showPermissionError = false;
|
||||
}
|
||||
});
|
||||
|
||||
$scope.$on("$routeChangeError", function (event, current, previous, rejection) {
|
||||
if (rejection.status === 403) {
|
||||
$scope.showPermissionError = true;
|
||||
}
|
||||
});
|
||||
|
||||
$scope.version = clientConfig.version;
|
||||
$scope.newVersionAvailable = clientConfig.newVersionAvailable && currentUser.hasPermission("admin");
|
||||
|
||||
$scope.dashboards = [];
|
||||
$scope.reloadDashboards = function () {
|
||||
@@ -194,41 +199,17 @@
|
||||
});
|
||||
};
|
||||
|
||||
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();
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var PersonalIndexCtrl = function ($scope, Events, Dashboard, Query) {
|
||||
var IndexCtrl = function ($scope, Events, Dashboard, Query) {
|
||||
Events.record(currentUser, "view", "page", "personal_homepage");
|
||||
$scope.$parent.pageTitle = "Home";
|
||||
|
||||
$scope.recentQueries = Query.recent();
|
||||
$scope.recentDashboards = Dashboard.recent();
|
||||
|
||||
$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('PersonalIndexCtrl', ['$scope', 'Events', 'Dashboard', 'Query', PersonalIndexCtrl])
|
||||
.controller('IndexCtrl', ['$scope', 'Events', 'Dashboard', 'Query', IndexCtrl])
|
||||
.controller('MainCtrl', ['$scope', '$location', 'Dashboard', 'notifications', MainCtrl])
|
||||
.controller('QuerySearchCtrl', ['$scope', '$location', '$filter', 'Events', 'Query', QuerySearchCtrl]);
|
||||
})();
|
||||
|
||||
@@ -3,69 +3,69 @@
|
||||
$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);
|
||||
var renderDashboard = function (dashboard) {
|
||||
$scope.$parent.pageTitle = dashboard.name;
|
||||
|
||||
$scope.$parent.pageTitle = dashboard.name;
|
||||
var promises = [];
|
||||
|
||||
var promises = [];
|
||||
_.each($scope.dashboard.widgets, function (row) {
|
||||
return _.each(row, function (widget) {
|
||||
if (widget.visualization) {
|
||||
var queryResult = widget.getQuery().getQueryResult();
|
||||
if (angular.isDefined(queryResult))
|
||||
promises.push(queryResult.toPromise());
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$scope.dashboard.widgets = _.map($scope.dashboard.widgets, function (row) {
|
||||
return _.map(row, function (widget) {
|
||||
var w = new Widget(widget);
|
||||
$q.all(promises).then(function(queryResults) {
|
||||
var filters = {};
|
||||
_.each(queryResults, function(queryResult) {
|
||||
var queryFilters = queryResult.getFilters();
|
||||
_.each(queryFilters, function (queryFilter) {
|
||||
var hasQueryStringValue = _.has($location.search(), queryFilter.name);
|
||||
|
||||
if (w.visualization) {
|
||||
promises.push(w.getQuery().getQueryResult().toPromise());
|
||||
if (!(hasQueryStringValue || dashboard.dashboard_filters_enabled)) {
|
||||
// If dashboard filters not enabled, or no query string value given, skip filters linking.
|
||||
return;
|
||||
}
|
||||
|
||||
return w;
|
||||
});
|
||||
});
|
||||
|
||||
$q.all(promises).then(function(queryResults) {
|
||||
var filters = {};
|
||||
_.each(queryResults, function(queryResult) {
|
||||
var queryFilters = queryResult.getFilters();
|
||||
_.each(queryFilters, function (queryFilter) {
|
||||
var hasQueryStringValue = _.has($location.search(), queryFilter.name);
|
||||
|
||||
if (!(hasQueryStringValue || dashboard.dashboard_filters_enabled)) {
|
||||
// If dashboard filters not enabled, or no query string value given, skip filters linking.
|
||||
return;
|
||||
if (!_.has(filters, queryFilter.name)) {
|
||||
var filter = _.extend({}, queryFilter);
|
||||
filters[filter.name] = filter;
|
||||
filters[filter.name].originFilters = [];
|
||||
if (hasQueryStringValue) {
|
||||
filter.current = $location.search()[filter.name];
|
||||
}
|
||||
|
||||
if (!_.has(filters, queryFilter.name)) {
|
||||
var filter = _.extend({}, queryFilter);
|
||||
filters[filter.name] = filter;
|
||||
filters[filter.name].originFilters = [];
|
||||
if (hasQueryStringValue) {
|
||||
filter.current = $location.search()[filter.name];
|
||||
}
|
||||
|
||||
$scope.$watch(function () { return filter.current }, function (value) {
|
||||
_.each(filter.originFilters, function (originFilter) {
|
||||
originFilter.current = value;
|
||||
});
|
||||
$scope.$watch(function () { return filter.current }, function (value) {
|
||||
_.each(filter.originFilters, function (originFilter) {
|
||||
originFilter.current = value;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: merge values.
|
||||
filters[queryFilter.name].originFilters.push(queryFilter);
|
||||
});
|
||||
// TODO: merge values.
|
||||
filters[queryFilter.name].originFilters.push(queryFilter);
|
||||
});
|
||||
|
||||
$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();
|
||||
$scope.filters = _.values(filters);
|
||||
});
|
||||
}
|
||||
|
||||
var loadDashboard = _.throttle(function () {
|
||||
$scope.dashboard = Dashboard.get({slug: $routeParams.dashboardSlug}, function (dashboard) {
|
||||
Events.record(currentUser, "view", "dashboard", dashboard.id);
|
||||
renderDashboard(dashboard);
|
||||
}, 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();
|
||||
@@ -94,15 +94,28 @@
|
||||
}
|
||||
};
|
||||
|
||||
$scope.archiveDashboard = function () {
|
||||
if (confirm('Are you sure you want to archive the "' + $scope.dashboard.name + '" dashboard?')) {
|
||||
Events.record(currentUser, "archive", "dashboard", $scope.dashboard.id);
|
||||
$scope.dashboard.$delete(function () {
|
||||
$scope.$parent.reloadDashboards();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$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;
|
||||
var refreshRate = _.min(_.map(_.flatten($scope.dashboard.widgets), function(widget) {
|
||||
var schedule = widget.visualization.query.schedule;
|
||||
if (schedule === null || schedule.match(/\d\d:\d\d/) !== null) {
|
||||
return 60;
|
||||
}
|
||||
return widget.visualization.query.schedule;
|
||||
}));
|
||||
|
||||
$scope.refreshRate = _.max([120, refreshRate * 2]) * 1000;
|
||||
|
||||
@@ -119,12 +132,16 @@
|
||||
|
||||
Events.record(currentUser, "delete", "widget", $scope.widget.id);
|
||||
|
||||
$scope.widget.$delete(function() {
|
||||
$scope.widget.$delete(function(response) {
|
||||
$scope.dashboard.widgets = _.map($scope.dashboard.widgets, function(row) {
|
||||
return _.filter(row, function(widget) {
|
||||
return widget.id != undefined;
|
||||
})
|
||||
});
|
||||
|
||||
$scope.dashboard.widgets = _.filter($scope.dashboard.widgets, function(row) { return row.length > 0 });
|
||||
|
||||
$scope.dashboard.layout = response.layout;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -138,9 +155,10 @@
|
||||
var parameters = Query.collectParamsFromQueryString($location, $scope.query);
|
||||
var maxAge = $location.search()['maxAge'];
|
||||
$scope.queryResult = $scope.query.getQueryResult(maxAge, parameters);
|
||||
$scope.nextUpdateTime = moment(new Date(($scope.query.updated_at + $scope.query.ttl + $scope.query.runtime + 300) * 1000)).fromNow();
|
||||
|
||||
$scope.type = 'visualization';
|
||||
} else if ($scope.widget.restricted) {
|
||||
$scope.type = 'restricted';
|
||||
} else {
|
||||
$scope.type = 'textbox';
|
||||
}
|
||||
|
||||
47
rd_ui/app/scripts/controllers/data_sources.js
Normal file
47
rd_ui/app/scripts/controllers/data_sources.js
Normal file
@@ -0,0 +1,47 @@
|
||||
(function () {
|
||||
var DataSourcesCtrl = function ($scope, $location, growl, Events, DataSource) {
|
||||
Events.record(currentUser, "view", "page", "admin/data_sources");
|
||||
$scope.$parent.pageTitle = "Data Sources";
|
||||
|
||||
$scope.dataSources = DataSource.query();
|
||||
|
||||
$scope.openDataSource = function(datasource) {
|
||||
$location.path('/data_sources/' + datasource.id);
|
||||
};
|
||||
|
||||
$scope.deleteDataSource = function(event, datasource) {
|
||||
event.stopPropagation();
|
||||
Events.record(currentUser, "delete", "datasource", datasource.id);
|
||||
datasource.$delete(function(resource) {
|
||||
growl.addSuccessMessage("Data source deleted successfully.");
|
||||
this.$parent.dataSources = _.without(this.dataSources, resource);
|
||||
}.bind(this), function(httpResponse) {
|
||||
console.log("Failed to delete data source: ", httpResponse.status, httpResponse.statusText, httpResponse.data);
|
||||
growl.addErrorMessage("Failed to delete data source.");
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
var DataSourceCtrl = function ($scope, $routeParams, $http, $location, Events, DataSource) {
|
||||
Events.record(currentUser, "view", "page", "admin/data_source");
|
||||
$scope.$parent.pageTitle = "Data Sources";
|
||||
|
||||
$scope.dataSourceId = $routeParams.dataSourceId;
|
||||
|
||||
if ($scope.dataSourceId == "new") {
|
||||
$scope.dataSource = new DataSource({options: {}});
|
||||
} else {
|
||||
$scope.dataSource = DataSource.get({id: $routeParams.dataSourceId});
|
||||
}
|
||||
|
||||
$scope.$watch('dataSource.id', function(id) {
|
||||
if (id != $scope.dataSourceId && id !== undefined) {
|
||||
$location.path('/data_sources/' + id).replace();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
angular.module('redash.controllers')
|
||||
.controller('DataSourcesCtrl', ['$scope', '$location', 'growl', 'Events', 'DataSource', DataSourcesCtrl])
|
||||
.controller('DataSourceCtrl', ['$scope', '$routeParams', '$http', '$location', 'Events', 'DataSource', DataSourceCtrl])
|
||||
})();
|
||||
@@ -17,8 +17,9 @@
|
||||
saveQuery = $scope.saveQuery;
|
||||
|
||||
$scope.sourceMode = true;
|
||||
$scope.canEdit = currentUser.canEdit($scope.query);
|
||||
$scope.canEdit = currentUser.canEdit($scope.query);// TODO: bring this back? || clientConfig.allowAllToEditQueries;
|
||||
$scope.isDirty = false;
|
||||
$scope.base_url = $location.protocol()+"://"+$location.host()+":"+$location.port();
|
||||
|
||||
$scope.newVisualization = undefined;
|
||||
|
||||
@@ -67,9 +68,9 @@
|
||||
|
||||
$scope.duplicateQuery = function() {
|
||||
Events.record(currentUser, 'fork', 'query', $scope.query.id);
|
||||
$scope.query.name = 'Copy of (#'+$scope.query.id+') '+$scope.query.name;
|
||||
$scope.query.id = null;
|
||||
$scope.query.ttl = -1;
|
||||
|
||||
$scope.query.schedule = null;
|
||||
$scope.saveQuery({
|
||||
successMessage: 'Query forked',
|
||||
errorMessage: 'Query could not be forked'
|
||||
|
||||
@@ -1,38 +1,128 @@
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
function QueryViewCtrl($scope, Events, $route, $location, notifications, growl, Query, DataSource) {
|
||||
function QueryViewCtrl($scope, Events, $route, $location, notifications, growl, $modal, Query, DataSource) {
|
||||
var DEFAULT_TAB = 'table';
|
||||
|
||||
var getQueryResult = function(ttl) {
|
||||
$scope.base_url = $location.protocol()+"://"+$location.host()+":"+$location.port();
|
||||
|
||||
var getQueryResult = function(maxAge) {
|
||||
// Collect params, and getQueryResult with params; getQueryResult merges it into the query
|
||||
var parameters = Query.collectParamsFromQueryString($location, $scope.query);
|
||||
if (ttl == undefined) {
|
||||
ttl = $location.search()['maxAge'];
|
||||
if (maxAge == undefined) {
|
||||
maxAge = $location.search()['maxAge'];
|
||||
}
|
||||
$scope.queryResult = $scope.query.getQueryResult(ttl, parameters);
|
||||
|
||||
if (maxAge == undefined) {
|
||||
maxAge = -1;
|
||||
}
|
||||
|
||||
$scope.showLog = false;
|
||||
$scope.queryResult = $scope.query.getQueryResult(maxAge, parameters);
|
||||
}
|
||||
|
||||
var getDataSourceId = function() {
|
||||
// Try to get the query's data source id
|
||||
var dataSourceId = $scope.query.data_source_id;
|
||||
|
||||
// If there is no source yet, then parse what we have in localStorage
|
||||
// e.g. `null` -> `NaN`, malformed data -> `NaN`, "1" -> 1
|
||||
if (dataSourceId === undefined) {
|
||||
dataSourceId = parseInt(localStorage.lastSelectedDataSourceId, 10);
|
||||
}
|
||||
|
||||
// If we had an invalid value in localStorage (e.g. nothing, deleted source), then use the first data source
|
||||
var isValidDataSourceId = !isNaN(dataSourceId) && _.some($scope.dataSources, function(ds) {
|
||||
return ds.id == dataSourceId;
|
||||
});
|
||||
|
||||
if (!isValidDataSourceId) {
|
||||
dataSourceId = $scope.dataSources[0].id;
|
||||
}
|
||||
|
||||
// Return our data source id
|
||||
return dataSourceId;
|
||||
}
|
||||
|
||||
var updateDataSources = function(dataSources) {
|
||||
// Filter out data sources the user can't query (or used by current query):
|
||||
$scope.dataSources = _.filter(dataSources, function(dataSource) {
|
||||
return !dataSource.view_only || dataSource.id === $scope.query.data_source_id;
|
||||
});
|
||||
|
||||
if ($scope.dataSources.length == 0) {
|
||||
$scope.noDataSources = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if ($scope.query.isNew()) {
|
||||
$scope.query.data_source_id = getDataSourceId();
|
||||
}
|
||||
|
||||
$scope.dataSource = _.find(dataSources, function(ds) { return ds.id == $scope.query.data_source_id; });
|
||||
|
||||
//$scope.canExecuteQuery = $scope.canExecuteQuery && _.some(dataSources, function(ds) { return !ds.view_only });
|
||||
$scope.canCreateQuery = _.any(dataSources, function(ds) { return !ds.view_only });
|
||||
|
||||
updateSchema();
|
||||
}
|
||||
|
||||
|
||||
$scope.dataSource = {};
|
||||
$scope.query = $route.current.locals.query;
|
||||
|
||||
var updateSchema = function() {
|
||||
$scope.hasSchema = false;
|
||||
$scope.editorSize = "col-md-12";
|
||||
DataSource.getSchema({id: $scope.query.data_source_id}, function(data) {
|
||||
if (data && data.length > 0) {
|
||||
$scope.schema = data;
|
||||
_.each(data, function(table) {
|
||||
table.collapsed = true;
|
||||
});
|
||||
|
||||
$scope.editorSize = "col-md-9";
|
||||
$scope.hasSchema = true;
|
||||
} else {
|
||||
$scope.hasSchema = false;
|
||||
$scope.editorSize = "col-md-12";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Events.record(currentUser, 'view', 'query', $scope.query.id);
|
||||
getQueryResult();
|
||||
$scope.queryExecuting = false;
|
||||
|
||||
$scope.isQueryOwner = currentUser.id === $scope.query.user.id;
|
||||
$scope.isQueryOwner = (currentUser.id === $scope.query.user.id) || currentUser.hasPermission('admin');
|
||||
$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;
|
||||
});
|
||||
$scope.canExecuteQuery = function() {
|
||||
return currentUser.hasPermission('execute_query') && !$scope.dataSource.view_only;
|
||||
}
|
||||
|
||||
$scope.canScheduleQuery = currentUser.hasPermission('schedule_query');
|
||||
|
||||
if ($route.current.locals.dataSources) {
|
||||
$scope.dataSources = $route.current.locals.dataSources;
|
||||
updateDataSources($route.current.locals.dataSources);
|
||||
} else {
|
||||
$scope.dataSources = DataSource.query(updateDataSources);
|
||||
}
|
||||
|
||||
// in view mode, latest dataset is always visible
|
||||
// source mode changes this behavior
|
||||
$scope.showDataset = true;
|
||||
$scope.showLog = false;
|
||||
|
||||
$scope.lockButton = function(lock) {
|
||||
$scope.queryExecuting = lock;
|
||||
};
|
||||
|
||||
$scope.showApiKey = function() {
|
||||
alert("API Key for this query:\n" + $scope.query.api_key);
|
||||
};
|
||||
|
||||
$scope.saveQuery = function(options, data) {
|
||||
if (data) {
|
||||
data.id = $scope.query.id;
|
||||
@@ -66,6 +156,14 @@
|
||||
};
|
||||
|
||||
$scope.executeQuery = function() {
|
||||
if (!$scope.canExecuteQuery()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$scope.query.query) {
|
||||
return;
|
||||
}
|
||||
|
||||
getQueryResult(0);
|
||||
$scope.lockButton(true);
|
||||
$scope.cancelling = false;
|
||||
@@ -77,24 +175,24 @@
|
||||
$scope.queryResult.cancelExecution();
|
||||
Events.record(currentUser, 'cancel_execute', 'query', $scope.query.id);
|
||||
};
|
||||
|
||||
|
||||
$scope.archiveQuery = function(options, data) {
|
||||
if (data) {
|
||||
data.id = $scope.query.id;
|
||||
} else {
|
||||
data = $scope.query;
|
||||
}
|
||||
|
||||
|
||||
$scope.isDirty = false;
|
||||
|
||||
|
||||
options = _.extend({}, {
|
||||
successMessage: 'Query archived',
|
||||
errorMessage: 'Query could not be archived'
|
||||
}, options);
|
||||
|
||||
|
||||
return Query.delete({id: data.id}, function() {
|
||||
$scope.query.is_archived = true;
|
||||
$scope.query.ttl = -1;
|
||||
$scope.query.schedule = null;
|
||||
growl.addSuccessMessage(options.successMessage);
|
||||
// This feels dirty.
|
||||
$('#archive-confirmation-modal').modal('hide');
|
||||
@@ -105,6 +203,7 @@
|
||||
|
||||
$scope.updateDataSource = function() {
|
||||
Events.record(currentUser, 'update_data_source', 'query', $scope.query.id);
|
||||
localStorage.lastSelectedDataSourceId = $scope.query.data_source_id;
|
||||
|
||||
$scope.query.latest_query_data = null;
|
||||
$scope.query.latest_query_data_id = null;
|
||||
@@ -117,6 +216,8 @@
|
||||
});
|
||||
}
|
||||
|
||||
updateSchema();
|
||||
$scope.dataSource = _.find($scope.dataSources, function(ds) { return ds.id == $scope.query.data_source_id; });
|
||||
$scope.executeQuery();
|
||||
};
|
||||
|
||||
@@ -162,8 +263,34 @@
|
||||
if (status === 'done' || status === 'failed') {
|
||||
$scope.lockButton(false);
|
||||
}
|
||||
|
||||
if ($scope.queryResult.getLog() != null) {
|
||||
$scope.showLog = true;
|
||||
}
|
||||
});
|
||||
|
||||
$scope.openScheduleForm = function() {
|
||||
if (!$scope.isQueryOwner || !$scope.canScheduleQuery) {
|
||||
return;
|
||||
};
|
||||
|
||||
$modal.open({
|
||||
templateUrl: '/views/schedule_form.html',
|
||||
size: 'sm',
|
||||
scope: $scope,
|
||||
controller: ['$scope', '$modalInstance', function($scope, $modalInstance) {
|
||||
$scope.close = function() {
|
||||
$modalInstance.close();
|
||||
}
|
||||
if ($scope.query.hasDailySchedule()) {
|
||||
$scope.refreshType = 'daily';
|
||||
} else {
|
||||
$scope.refreshType = 'periodic';
|
||||
}
|
||||
}]
|
||||
});
|
||||
};
|
||||
|
||||
$scope.$watch(function() {
|
||||
return $location.hash()
|
||||
}, function(hash) {
|
||||
@@ -176,5 +303,5 @@
|
||||
|
||||
angular.module('redash.controllers')
|
||||
.controller('QueryViewCtrl',
|
||||
['$scope', 'Events', '$route', '$location', 'notifications', 'growl', 'Query', 'DataSource', QueryViewCtrl]);
|
||||
['$scope', 'Events', '$route', '$location', 'notifications', 'growl', '$modal', 'Query', 'DataSource', QueryViewCtrl]);
|
||||
})();
|
||||
|
||||
355
rd_ui/app/scripts/controllers/users.js
Normal file
355
rd_ui/app/scripts/controllers/users.js
Normal file
@@ -0,0 +1,355 @@
|
||||
(function () {
|
||||
var GroupsCtrl = function ($scope, $location, $modal, growl, Events, Group) {
|
||||
Events.record(currentUser, "view", "page", "groups");
|
||||
$scope.$parent.pageTitle = "Groups";
|
||||
|
||||
$scope.gridConfig = {
|
||||
isPaginationEnabled: true,
|
||||
itemsByPage: 20,
|
||||
maxSize: 8,
|
||||
};
|
||||
|
||||
$scope.gridColumns = [
|
||||
{
|
||||
"label": "Name",
|
||||
"map": "name",
|
||||
"cellTemplate": '<a href="groups/{{dataRow.id}}">{{dataRow.name}}</a>'
|
||||
}
|
||||
];
|
||||
|
||||
$scope.groups = [];
|
||||
Group.query(function(groups) {
|
||||
$scope.groups = groups;
|
||||
});
|
||||
|
||||
$scope.newGroup = function() {
|
||||
$modal.open({
|
||||
templateUrl: '/views/groups/edit_group_form.html',
|
||||
size: 'sm',
|
||||
resolve: {
|
||||
group: function() { return new Group({}); }
|
||||
},
|
||||
controller: ['$scope', '$modalInstance', 'group', function($scope, $modalInstance, group) {
|
||||
$scope.group = group;
|
||||
var newGroup = group.id === undefined;
|
||||
|
||||
if (newGroup) {
|
||||
$scope.saveButtonText = "Create";
|
||||
$scope.title = "Create a New Group";
|
||||
} else {
|
||||
$scope.saveButtonText = "Save";
|
||||
$scope.title = "Edit Group";
|
||||
}
|
||||
|
||||
$scope.ok = function() {
|
||||
$scope.group.$save(function(group) {
|
||||
if (newGroup) {
|
||||
$location.path('/groups/' + group.id).replace();
|
||||
$modalInstance.close();
|
||||
} else {
|
||||
$modalInstance.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$scope.cancel = function() {
|
||||
$modalInstance.close();
|
||||
}
|
||||
}]
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
var usersNav = function($location) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
replace: true,
|
||||
template:
|
||||
'<ul class="nav nav-tabs">' +
|
||||
'<li role="presentation" ng-class="{\'active\': usersPage }"><a href="users">Users</a></li>' +
|
||||
'<li role="presentation" ng-class="{\'active\': groupsPage }" ng-if="showGroupsLink"><a href="groups">Groups</a></li>' +
|
||||
'</ul>',
|
||||
controller: ['$scope', function ($scope) {
|
||||
$scope.usersPage = _.string.startsWith($location.path(), '/users');
|
||||
$scope.groupsPage = _.string.startsWith($location.path(), '/groups');
|
||||
$scope.showGroupsLink = currentUser.hasPermission('list_users');
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
var groupName = function ($location, growl) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
scope: {
|
||||
'group': '='
|
||||
},
|
||||
transclude: true,
|
||||
template:
|
||||
'<h2>'+
|
||||
'<edit-in-place editable="canEdit()" done="saveName" ignore-blanks=\'true\' value="group.name"></edit-in-place> ' +
|
||||
'<button class="btn btn-xs btn-danger" ng-if="canEdit()" ng-click="deleteGroup()">Delete this group</button>' +
|
||||
'</h2>',
|
||||
replace: true,
|
||||
controller: ['$scope', function ($scope) {
|
||||
$scope.canEdit = function() {
|
||||
return currentUser.isAdmin && $scope.group.type != 'builtin';
|
||||
};
|
||||
|
||||
$scope.saveName = function() {
|
||||
$scope.group.$save();
|
||||
};
|
||||
|
||||
$scope.deleteGroup = function() {
|
||||
if (confirm("Are you sure you want to delete this group?")) {
|
||||
$scope.group.$delete(function() {
|
||||
$location.path('/groups').replace();
|
||||
growl.addSuccessMessage("Group deleted successfully.");
|
||||
})
|
||||
}
|
||||
}
|
||||
}]
|
||||
}
|
||||
};
|
||||
|
||||
var GroupDataSourcesCtrl = function($scope, $routeParams, $http, $location, growl, Events, Group, DataSource) {
|
||||
Events.record(currentUser, "view", "group_data_sources", $scope.groupId);
|
||||
$scope.group = Group.get({id: $routeParams.groupId});
|
||||
$scope.dataSources = Group.dataSources({id: $routeParams.groupId});
|
||||
$scope.newDataSource = {};
|
||||
|
||||
$scope.findDataSource = function(search) {
|
||||
if ($scope.foundDataSources === undefined) {
|
||||
DataSource.query(function(dataSources) {
|
||||
var existingIds = _.map($scope.dataSources, function(m) { return m.id; });
|
||||
$scope.foundDataSources = _.filter(dataSources, function(ds) { return !_.contains(existingIds, ds.id); });
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
$scope.addDataSource = function(dataSource) {
|
||||
// Clear selection, to clear up the input control.
|
||||
$scope.newDataSource.selected = undefined;
|
||||
|
||||
$http.post('api/groups/' + $routeParams.groupId + '/data_sources', {'data_source_id': dataSource.id}).success(function(user) {
|
||||
dataSource.view_only = false;
|
||||
$scope.dataSources.unshift(dataSource);
|
||||
|
||||
if ($scope.foundDataSources) {
|
||||
$scope.foundDataSources = _.filter($scope.foundDataSources, function(ds) { return ds != dataSource; });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
$scope.changePermission = function(dataSource, viewOnly) {
|
||||
$http.post('api/groups/' + $routeParams.groupId + '/data_sources/' + dataSource.id, {view_only: viewOnly}).success(function() {
|
||||
dataSource.view_only = viewOnly;
|
||||
});
|
||||
};
|
||||
|
||||
$scope.removeDataSource = function(dataSource) {
|
||||
$http.delete('api/groups/' + $routeParams.groupId + '/data_sources/' + dataSource.id).success(function() {
|
||||
$scope.dataSources = _.filter($scope.dataSources, function(ds) { return dataSource != ds; });
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
var GroupCtrl = function($scope, $routeParams, $http, $location, growl, Events, Group, User) {
|
||||
Events.record(currentUser, "view", "group", $scope.groupId);
|
||||
$scope.group = Group.get({id: $routeParams.groupId});
|
||||
$scope.members = Group.members({id: $routeParams.groupId});
|
||||
$scope.newMember = {};
|
||||
|
||||
$scope.findUser = function(search) {
|
||||
if (search == "") {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($scope.foundUsers === undefined) {
|
||||
User.query(function(users) {
|
||||
var existingIds = _.map($scope.members, function(m) { return m.id; });
|
||||
_.each(users, function(user) { user.alreadyMember = _.contains(existingIds, user.id); });
|
||||
$scope.foundUsers = users;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
$scope.addMember = function(user) {
|
||||
// Clear selection, to clear up the input control.
|
||||
$scope.newMember.selected = undefined;
|
||||
|
||||
$http.post('api/groups/' + $routeParams.groupId + '/members', {'user_id': user.id}).success(function() {
|
||||
$scope.members.unshift(user);
|
||||
user.alreadyMember = true;
|
||||
});
|
||||
};
|
||||
|
||||
$scope.removeMember = function(member) {
|
||||
$http.delete('api/groups/' + $routeParams.groupId + '/members/' + member.id).success(function() {
|
||||
$scope.members = _.filter($scope.members, function(m) { return m != member });
|
||||
|
||||
if ($scope.foundUsers) {
|
||||
_.each($scope.foundUsers, function(user) { if (user.id == member.id) { user.alreadyMember = false }; });
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
var UsersCtrl = function ($scope, $location, growl, Events, User) {
|
||||
Events.record(currentUser, "view", "page", "users");
|
||||
$scope.$parent.pageTitle = "Users";
|
||||
|
||||
$scope.gridConfig = {
|
||||
isPaginationEnabled: true,
|
||||
itemsByPage: 20,
|
||||
maxSize: 8,
|
||||
};
|
||||
|
||||
$scope.gridColumns = [
|
||||
{
|
||||
"label": "Name",
|
||||
"map": "name",
|
||||
"cellTemplate": '<img src="{{dataRow.gravatar_url}}" height="40px"/> <a href="users/{{dataRow.id}}">{{dataRow.name}}</a>'
|
||||
},
|
||||
{
|
||||
'label': 'Joined',
|
||||
'cellTemplate': '<span am-time-ago="dataRow.created_at"></span>'
|
||||
}
|
||||
];
|
||||
|
||||
$scope.users = [];
|
||||
User.query(function(users) {
|
||||
$scope.users = users;
|
||||
});
|
||||
};
|
||||
|
||||
var UserCtrl = function ($scope, $routeParams, $http, $location, growl, Events, User) {
|
||||
$scope.$parent.pageTitle = "Users";
|
||||
|
||||
$scope.userId = $routeParams.userId;
|
||||
|
||||
if ($scope.userId === 'me') {
|
||||
$scope.userId = currentUser.id;
|
||||
}
|
||||
Events.record(currentUser, "view", "user", $scope.userId);
|
||||
$scope.canEdit = currentUser.hasPermission("admin") || currentUser.id === parseInt($scope.userId);
|
||||
$scope.showSettings = false;
|
||||
$scope.showPasswordSettings = false;
|
||||
|
||||
$scope.selectTab = function(tab) {
|
||||
_.each($scope.tabs, function(v, k) {
|
||||
$scope.tabs[k] = (k === tab);
|
||||
});
|
||||
};
|
||||
|
||||
$scope.setTab = function(tab) {
|
||||
$location.hash(tab);
|
||||
}
|
||||
|
||||
$scope.tabs = {
|
||||
profile: false,
|
||||
apiKey: false,
|
||||
settings: false,
|
||||
password: false
|
||||
};
|
||||
|
||||
$scope.selectTab($location.hash() || 'profile');
|
||||
|
||||
$scope.user = User.get({id: $scope.userId}, function(user) {
|
||||
if (user.auth_type == 'password') {
|
||||
$scope.showSettings = $scope.canEdit;
|
||||
$scope.showPasswordSettings = $scope.canEdit;
|
||||
}
|
||||
});
|
||||
|
||||
$scope.password = {
|
||||
current: '',
|
||||
new: '',
|
||||
newRepeat: ''
|
||||
};
|
||||
|
||||
$scope.savePassword = function(form) {
|
||||
$scope.$broadcast('show-errors-check-validity');
|
||||
|
||||
if (!form.$valid) {
|
||||
return;
|
||||
}
|
||||
|
||||
var data = {
|
||||
id: $scope.user.id,
|
||||
password: $scope.password.new,
|
||||
old_password: $scope.password.current
|
||||
};
|
||||
|
||||
User.save(data, function() {
|
||||
growl.addSuccessMessage("Password Saved.")
|
||||
$scope.password = {
|
||||
current: '',
|
||||
new: '',
|
||||
newRepeat: ''
|
||||
};
|
||||
}, function(error) {
|
||||
var message = error.data.message || "Failed saving password.";
|
||||
growl.addErrorMessage(message);
|
||||
});
|
||||
};
|
||||
|
||||
$scope.updateUser = function(form) {
|
||||
$scope.$broadcast('show-errors-check-validity');
|
||||
|
||||
if (!form.$valid) {
|
||||
return;
|
||||
}
|
||||
|
||||
var data = {
|
||||
id: $scope.user.id,
|
||||
name: $scope.user.name,
|
||||
email: $scope.user.email
|
||||
};
|
||||
|
||||
if ($scope.user.admin === true && $scope.user.groups.indexOf("admin") === -1) {
|
||||
data.groups = $scope.user.groups.concat("admin");
|
||||
} else if ($scope.user.admin === false && $scope.user.groups.indexOf("admin") !== -1) {
|
||||
data.groups = _.without($scope.user.groups, "admin");
|
||||
}
|
||||
|
||||
User.save(data, function(user) {
|
||||
growl.addSuccessMessage("Saved.")
|
||||
$scope.user = user;
|
||||
}, function(error) {
|
||||
var message = error.data.message || "Failed saving.";
|
||||
growl.addErrorMessage(message);
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
var NewUserCtrl = function ($scope, $location, growl, Events, User) {
|
||||
Events.record(currentUser, "view", "page", "users/new");
|
||||
|
||||
$scope.user = new User({});
|
||||
$scope.saveUser = function() {
|
||||
$scope.$broadcast('show-errors-check-validity');
|
||||
|
||||
if (!$scope.userForm.$valid) {
|
||||
return;
|
||||
}
|
||||
|
||||
$scope.user.$save(function(user) {
|
||||
growl.addSuccessMessage("Saved.")
|
||||
$location.path('/users/' + user.id).replace();
|
||||
}, function(error) {
|
||||
var message = error.data.message || "Failed saving.";
|
||||
growl.addErrorMessage(message);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
angular.module('redash.controllers')
|
||||
.controller('GroupsCtrl', ['$scope', '$location', '$modal', 'growl', 'Events', 'Group', GroupsCtrl])
|
||||
.directive('groupName', ['$location', 'growl', groupName])
|
||||
.directive('usersNav', ['$location', usersNav])
|
||||
.controller('GroupCtrl', ['$scope', '$routeParams', '$http', '$location', 'growl', 'Events', 'Group', 'User', GroupCtrl])
|
||||
.controller('GroupDataSourcesCtrl', ['$scope', '$routeParams', '$http', '$location', 'growl', 'Events', 'Group', 'DataSource', GroupDataSourcesCtrl])
|
||||
.controller('UsersCtrl', ['$scope', '$location', 'growl', 'Events', 'User', UsersCtrl])
|
||||
.controller('UserCtrl', ['$scope', '$routeParams', '$http', '$location', 'growl', 'Events', 'User', UserCtrl])
|
||||
.controller('NewUserCtrl', ['$scope', '$location', 'growl', 'Events', 'User', NewUserCtrl])
|
||||
})();
|
||||
@@ -31,7 +31,7 @@
|
||||
'<div class="panel-heading">{name}' +
|
||||
'</div></li>';
|
||||
|
||||
$scope.$watch('dashboard.widgets && dashboard.widgets.length', function(widgets_length) {
|
||||
$scope.$watch('dashboard.layout', function() {
|
||||
$timeout(function() {
|
||||
gridster.remove_all_widgets();
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}, true);
|
||||
|
||||
$scope.saveDashboard = function() {
|
||||
$scope.saveInProgress = true;
|
||||
@@ -81,18 +81,15 @@
|
||||
$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);
|
||||
Dashboard.save({slug: $scope.dashboard.id, name: $scope.dashboard.name, layout: layout}, function(dashboard) {
|
||||
$scope.dashboard = dashboard;
|
||||
$scope.saveInProgress = false;
|
||||
$(element).modal('hide');
|
||||
});
|
||||
Events.record(currentUser, 'edit', 'dashboard', $scope.dashboard.id);
|
||||
} else {
|
||||
|
||||
$http.post('/api/dashboards', {
|
||||
$http.post('api/dashboards', {
|
||||
'name': $scope.dashboard.name
|
||||
}).success(function(response) {
|
||||
$(element).modal('hide');
|
||||
@@ -142,6 +139,11 @@
|
||||
|
||||
$scope.setType = function (type) {
|
||||
$scope.type = type;
|
||||
if (type == 'textbox') {
|
||||
$scope.widgetSizes.push({name: 'Hidden', value: 0});
|
||||
} else if ($scope.widgetSizes.length > 2) {
|
||||
$scope.widgetSizes.pop();
|
||||
}
|
||||
};
|
||||
|
||||
var reset = function() {
|
||||
@@ -186,7 +188,6 @@
|
||||
|
||||
$scope.saveWidget = function() {
|
||||
$scope.saveInProgress = true;
|
||||
|
||||
var widget = new Widget({
|
||||
'visualization_id': $scope.selectedVis && $scope.selectedVis.id,
|
||||
'dashboard_id': $scope.dashboard.id,
|
||||
@@ -219,4 +220,4 @@
|
||||
}
|
||||
}
|
||||
])
|
||||
})();
|
||||
})();
|
||||
|
||||
80
rd_ui/app/scripts/directives/data_source_directives.js
Normal file
80
rd_ui/app/scripts/directives/data_source_directives.js
Normal file
@@ -0,0 +1,80 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var directives = angular.module('redash.directives');
|
||||
|
||||
// Angular strips data- from the directive, so data-source-form becomes sourceForm...
|
||||
directives.directive('sourceForm', ['$http', 'growl', function ($http, growl) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
replace: true,
|
||||
templateUrl: '/views/data_sources/form.html',
|
||||
scope: {
|
||||
'dataSource': '='
|
||||
},
|
||||
link: function ($scope) {
|
||||
var setType = function(types) {
|
||||
if ($scope.dataSource.type === undefined) {
|
||||
$scope.dataSource.type = types[0].type;
|
||||
return types[0];
|
||||
}
|
||||
|
||||
$scope.type = _.find(types, function (t) {
|
||||
return t.type == $scope.dataSource.type;
|
||||
});
|
||||
};
|
||||
|
||||
$scope.files = {};
|
||||
|
||||
$scope.$watchCollection('files', function() {
|
||||
_.each($scope.files, function(v, k) {
|
||||
if (v) {
|
||||
$scope.dataSource.options[k] = v.base64;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$http.get('api/data_sources/types').success(function (types) {
|
||||
setType(types);
|
||||
|
||||
$scope.dataSourceTypes = types;
|
||||
|
||||
_.each(types, function (type) {
|
||||
_.each(type.configuration_schema.properties, function (prop, name) {
|
||||
if (name == 'password' || name == 'passwd') {
|
||||
prop.type = 'password';
|
||||
}
|
||||
|
||||
if (_.string.endsWith(name, "File")) {
|
||||
prop.type = 'file';
|
||||
}
|
||||
|
||||
if (prop.type == 'boolean') {
|
||||
prop.type = 'checkbox';
|
||||
}
|
||||
|
||||
prop.required = _.contains(type.configuration_schema.required, name);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
$scope.$watch('dataSource.type', function(current, prev) {
|
||||
if (prev !== current) {
|
||||
if (prev !== undefined) {
|
||||
$scope.dataSource.options = {};
|
||||
}
|
||||
setType($scope.dataSourceTypes);
|
||||
}
|
||||
});
|
||||
|
||||
$scope.saveChanges = function() {
|
||||
$scope.dataSource.$save(function() {
|
||||
growl.addSuccessMessage("Saved.");
|
||||
}, function() {
|
||||
growl.addErrorMessage("Failed saving.");
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}]);
|
||||
})();
|
||||
@@ -40,7 +40,7 @@
|
||||
}
|
||||
}]);
|
||||
|
||||
directives.directive('rdTab', function () {
|
||||
directives.directive('rdTab', ['$location', function ($location) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
scope: {
|
||||
@@ -48,9 +48,10 @@
|
||||
'name': '@'
|
||||
},
|
||||
transclude: true,
|
||||
template: '<li class="rd-tab" ng-class="{active: tabId==selectedTab}"><a href="#{{tabId}}">{{name}}<span ng-transclude></span></a></li>',
|
||||
template: '<li class="rd-tab" ng-class="{active: tabId==selectedTab}"><a href="{{basePath}}#{{tabId}}">{{name}}<span ng-transclude></span></a></li>',
|
||||
replace: true,
|
||||
link: function (scope) {
|
||||
scope.basePath = $location.path().substring(1);
|
||||
scope.$watch(function () {
|
||||
return scope.$parent.selectedTab
|
||||
}, function (tab) {
|
||||
@@ -58,7 +59,7 @@
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}]);
|
||||
|
||||
directives.directive('rdTabs', ['$location', function ($location) {
|
||||
return {
|
||||
@@ -67,9 +68,10 @@
|
||||
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>',
|
||||
template: '<ul class="nav nav-tabs"><li ng-class="{active: tab==selectedTab}" ng-repeat="tab in tabsCollection"><a href="{{basePath}}#{{tab.key}}">{{tab.name}}</a></li></ul>',
|
||||
replace: true,
|
||||
link: function ($scope, element, attrs) {
|
||||
$scope.basePath = $location.path().substring(1);
|
||||
$scope.selectTab = function (tabKey) {
|
||||
$scope.selectedTab = _.find($scope.tabsCollection, function (tab) {
|
||||
return tab.key == tabKey;
|
||||
@@ -247,4 +249,82 @@
|
||||
};
|
||||
}]
|
||||
);
|
||||
|
||||
directives.directive('compareTo', function () {
|
||||
return {
|
||||
require: "ngModel",
|
||||
scope: {
|
||||
otherModelValue: "=compareTo"
|
||||
},
|
||||
link: function (scope, element, attributes, ngModel) {
|
||||
var validate = function(value) {
|
||||
ngModel.$setValidity("compareTo", value === scope.otherModelValue);
|
||||
};
|
||||
|
||||
scope.$watch("otherModelValue", function() {
|
||||
validate(ngModel.$modelValue);
|
||||
});
|
||||
|
||||
ngModel.$parsers.push(function(value) {
|
||||
validate(value);
|
||||
return value;
|
||||
});
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
directives.directive('inputErrors', function () {
|
||||
return {
|
||||
restrict: "E",
|
||||
templateUrl: "/views/directives/input_errors.html",
|
||||
replace: true,
|
||||
scope: {
|
||||
errors: "="
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
directives.directive('onDestroy', function () {
|
||||
/* This directive can be used to invoke a callback when an element is destroyed,
|
||||
A useful example is the following:
|
||||
<div ng-if="includeText" on-destroy="form.text = null;">
|
||||
<input type="text" ng-model="form.text">
|
||||
</div>
|
||||
*/
|
||||
return {
|
||||
restrict: "A",
|
||||
scope: {
|
||||
onDestroy: "&",
|
||||
},
|
||||
link: function(scope, elem, attrs) {
|
||||
scope.$on('$destroy', function() {
|
||||
scope.onDestroy();
|
||||
});
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
directives.directive('colorBox', function () {
|
||||
return {
|
||||
restrict: "E",
|
||||
scope: {color: "="},
|
||||
template: "<span style='width: 12px; height: 12px; background-color: {{color}}; display: inline-block; margin-right: 5px;'></span>"
|
||||
};
|
||||
});
|
||||
|
||||
directives.directive('overlay', function() {
|
||||
return {
|
||||
restrict: "E",
|
||||
transclude: true,
|
||||
template: "" +
|
||||
'<div>' +
|
||||
'<div class="overlay"></div>' +
|
||||
'<div style="width: 100%; position:absolute; top:50px; z-index:2000">' +
|
||||
'<div class="well well-lg" style="width: 70%; margin: auto;" ng-transclude>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
}
|
||||
})
|
||||
|
||||
})();
|
||||
|
||||
251
rd_ui/app/scripts/directives/plotly.js
Normal file
251
rd_ui/app/scripts/directives/plotly.js
Normal file
@@ -0,0 +1,251 @@
|
||||
(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',
|
||||
'Brown': '#A52A2A',
|
||||
'Black': '#000000',
|
||||
'Gray': '#808080',
|
||||
'Pink': '#FFC0CB',
|
||||
'Dark Blue': '#00008b'
|
||||
};
|
||||
var ColorPaletteArray = _.values(ColorPalette)
|
||||
|
||||
var fillXValues = function(seriesList) {
|
||||
var xValues = _.uniq(_.flatten(_.pluck(seriesList, 'x')));
|
||||
xValues.sort();
|
||||
_.each(seriesList, function(series) {
|
||||
series.x.sort();
|
||||
_.each(xValues, function(value, index) {
|
||||
if (series.x[index] != value) {
|
||||
series.x.splice(index, 0, value);
|
||||
series.y.splice(index, 0, 0);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
var normalAreaStacking = function(seriesList) {
|
||||
fillXValues(seriesList);
|
||||
_.each(seriesList, function(series) {
|
||||
series.text = [];
|
||||
series.hoverinfo = 'text+name';
|
||||
});
|
||||
for (var i = 0; i < seriesList.length; i++) {
|
||||
for (var j = 0; j < seriesList[i].y.length; j++) {
|
||||
var sum = i > 0 ? seriesList[i-1].y[j] : 0;
|
||||
seriesList[i].text.push('Value: ' + seriesList[i].y[j] + '<br>Sum: ' + (sum + seriesList[i].y[j]));
|
||||
seriesList[i].y[j] += sum;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var percentAreaStacking = function(seriesList) {
|
||||
if (seriesList.length == 0)
|
||||
return;
|
||||
fillXValues(seriesList);
|
||||
_.each(seriesList, function(series) {
|
||||
series.text = [];
|
||||
series.hoverinfo = 'text+name';
|
||||
});
|
||||
for (var i = 0; i < seriesList[0].y.length; i++) {
|
||||
var sum = 0;
|
||||
for(var j = 0; j < seriesList.length; j++) {
|
||||
sum += seriesList[j].y[i];
|
||||
}
|
||||
for(var j = 0; j < seriesList.length; j++) {
|
||||
var value = seriesList[j].y[i] / sum * 100;
|
||||
seriesList[j].text.push('Value: ' + seriesList[j].y[i] + '<br>Relative: ' + value.toFixed(2) + '%');
|
||||
|
||||
seriesList[j].y[i] = value;
|
||||
if (j > 0)
|
||||
seriesList[j].y[i] += seriesList[j-1].y[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var percentBarStacking = function(seriesList) {
|
||||
if (seriesList.length == 0)
|
||||
return;
|
||||
fillXValues(seriesList);
|
||||
_.each(seriesList, function(series) {
|
||||
series.text = [];
|
||||
series.hoverinfo = 'text+name';
|
||||
});
|
||||
for (var i = 0; i < seriesList[0].y.length; i++) {
|
||||
var sum = 0;
|
||||
for(var j = 0; j < seriesList.length; j++) {
|
||||
sum += seriesList[j].y[i];
|
||||
}
|
||||
for(var j = 0; j < seriesList.length; j++) {
|
||||
var value = seriesList[j].y[i] / sum * 100;
|
||||
seriesList[j].text.push('Value: ' + seriesList[j].y[i] + '<br>Relative: ' + value.toFixed(2) + '%');
|
||||
seriesList[j].y[i] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var normalizeValue = function(value) {
|
||||
if (moment.isMoment(value)) {
|
||||
return value.format("YYYY-MM-DD HH:MM:SS.ssssss");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
angular.module('plotly-chart', [])
|
||||
.constant('ColorPalette', ColorPalette)
|
||||
.directive('plotlyChart', function () {
|
||||
return {
|
||||
restrict: 'E',
|
||||
template: '<plotly data="data" layout="layout" options="plotlyOptions"></plotly>',
|
||||
scope: {
|
||||
options: "=",
|
||||
series: "=",
|
||||
minHeight: "="
|
||||
},
|
||||
link: function (scope, element, attrs) {
|
||||
var getScaleType = function(scale) {
|
||||
if (scale == 'datetime')
|
||||
return 'date';
|
||||
if (scale == 'logarithmic')
|
||||
return 'log';
|
||||
return scale;
|
||||
}
|
||||
|
||||
var setType = function(series, type) {
|
||||
if (type == 'column') {
|
||||
series['type'] = 'bar';
|
||||
} else if (type == 'line') {
|
||||
series['mode'] = 'lines';
|
||||
} else if (type == 'area') {
|
||||
series['fill'] = scope.options.series.stacking == null ? 'tozeroy' : 'tonexty';
|
||||
series['mode'] = 'lines';
|
||||
} else if (type == 'scatter') {
|
||||
series['type'] = 'scatter';
|
||||
series['mode'] = 'markers';
|
||||
}
|
||||
}
|
||||
|
||||
var getColor = function(index) {
|
||||
return ColorPaletteArray[index % ColorPaletteArray.length];
|
||||
}
|
||||
|
||||
var bottomMargin = 50,
|
||||
pixelsPerLegendRow = 21;
|
||||
var redraw = function() {
|
||||
scope.data.length = 0;
|
||||
scope.layout.showlegend = _.has(scope.options, 'legend') ? scope.options.legend.enabled : true;
|
||||
delete scope.layout.barmode;
|
||||
delete scope.layout.xaxis;
|
||||
delete scope.layout.yaxis;
|
||||
delete scope.layout.yaxis2;
|
||||
|
||||
if (scope.options.globalSeriesType == 'pie') {
|
||||
var hasX = _.contains(_.values(scope.options.columnMapping), 'x');
|
||||
var rows = scope.series.length > 2 ? 2 : 1;
|
||||
var cellsInRow = Math.ceil(scope.series.length / rows)
|
||||
var cellWidth = 1 / cellsInRow;
|
||||
var cellHeight = 1 / rows;
|
||||
var xPadding = 0.02;
|
||||
var yPadding = 0.05;
|
||||
var largestXCount = 0;
|
||||
_.each(scope.series, function(series, index) {
|
||||
var xPosition = (index % cellsInRow) * cellWidth;
|
||||
var yPosition = Math.floor(index / cellsInRow) * cellHeight;
|
||||
var plotlySeries = {values: [], labels: [], type: 'pie', hole: .4,
|
||||
marker: {colors: ColorPaletteArray},
|
||||
text: series.name, textposition: 'inside', name: series.name,
|
||||
domain: {x: [xPosition, xPosition + cellWidth - xPadding],
|
||||
y: [yPosition, yPosition + cellHeight - yPadding]}};
|
||||
_.each(series.data, function(row, index) {
|
||||
plotlySeries.values.push(row.y);
|
||||
plotlySeries.labels.push(hasX ? row.x : 'Slice ' + index);
|
||||
});
|
||||
scope.data.push(plotlySeries);
|
||||
largestXCount = Math.max(largestXCount, plotlySeries.labels.length);
|
||||
});
|
||||
scope.layout.height = Math.max(scope.minHeight, pixelsPerLegendRow * largestXCount);
|
||||
scope.layout.margin.b = scope.layout.height - (scope.minHeight - bottomMargin);
|
||||
return;
|
||||
}
|
||||
scope.layout.height = Math.max(scope.minHeight, pixelsPerLegendRow * scope.series.length);
|
||||
scope.layout.margin.b = scope.layout.height - (scope.minHeight - bottomMargin);
|
||||
var hasY2 = false;
|
||||
_.each(scope.series, function(series, index) {
|
||||
var seriesOptions = scope.options.seriesOptions[series.name] || {};
|
||||
var plotlySeries = {x: [],
|
||||
y: [],
|
||||
name: seriesOptions.name || series.name,
|
||||
marker: {color: seriesOptions.color ? seriesOptions.color : getColor(index)}};
|
||||
if (seriesOptions.yAxis == 1 && (scope.options.series.stacking == null || seriesOptions.type == 'line')) {
|
||||
hasY2 = true;
|
||||
plotlySeries.yaxis = 'y2';
|
||||
}
|
||||
setType(plotlySeries, seriesOptions.type);
|
||||
var data = series.data;
|
||||
if (scope.options.sortX) {
|
||||
data = _.sortBy(data, 'x');
|
||||
}
|
||||
_.each(data, function(row) {
|
||||
plotlySeries.x.push(normalizeValue(row.x));
|
||||
plotlySeries.y.push(normalizeValue(row.y));
|
||||
});
|
||||
scope.data.push(plotlySeries)
|
||||
});
|
||||
|
||||
var getTitle = function(axis) {
|
||||
if (angular.isDefined(axis) && angular.isDefined(axis.title)) {
|
||||
return axis.title.text;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
scope.layout.xaxis = {title: getTitle(scope.options.xAxis),
|
||||
type: getScaleType(scope.options.xAxis.type)};
|
||||
if (angular.isDefined(scope.options.xAxis.labels)) {
|
||||
scope.layout.xaxis.showticklabels = scope.options.xAxis.labels.enabled;
|
||||
}
|
||||
if (angular.isArray(scope.options.yAxis)) {
|
||||
scope.layout.yaxis = {title: getTitle(scope.options.yAxis[0]),
|
||||
type: getScaleType(scope.options.yAxis[0].type)};
|
||||
}
|
||||
if (hasY2 && angular.isDefined(scope.options.yAxis)) {
|
||||
scope.layout.yaxis2 = {title: getTitle(scope.options.yAxis[1]),
|
||||
type: getScaleType(scope.options.yAxis[1].type),
|
||||
overlaying: 'y',
|
||||
side: 'right'};
|
||||
} else {
|
||||
delete scope.layout.yaxis2;
|
||||
}
|
||||
if (scope.options.series.stacking == 'normal') {
|
||||
scope.layout.barmode = 'stack';
|
||||
if (scope.options.globalSeriesType == 'area') {
|
||||
normalAreaStacking(scope.data);
|
||||
}
|
||||
} else if (scope.options.series.stacking == 'percent') {
|
||||
scope.layout.barmode = 'stack';
|
||||
if (scope.options.globalSeriesType == 'area') {
|
||||
percentAreaStacking(scope.data);
|
||||
} else if (scope.options.globalSeriesType == 'column') {
|
||||
percentBarStacking(scope.data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scope.$watch('series', redraw);
|
||||
scope.$watch('options', redraw, true);
|
||||
scope.layout = {margin: {l: 50, r: 50, b: 50, t: 20, pad: 4}, hovermode: 'closest'};
|
||||
scope.plotlyOptions = {showLink: false, displaylogo: false};
|
||||
scope.data = [];
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -8,9 +8,9 @@
|
||||
'query': '=',
|
||||
'visualization': '=?'
|
||||
},
|
||||
template: '<a ng-href="{{link}}" class="query-link">{{query.name}}</a>',
|
||||
template: '<small><span class="glyphicon glyphicon-link"></span></small> <a ng-href="{{link}}" class="query-link">{{query.name}}</a>',
|
||||
link: function(scope, element) {
|
||||
scope.link = '/queries/' + scope.query.id;
|
||||
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
|
||||
@@ -29,10 +29,10 @@
|
||||
restrict: 'E',
|
||||
template: '<span ng-show="query.id && canViewSource">\
|
||||
<a ng-show="!sourceMode"\
|
||||
ng-href="{{query.id}}/source#{{selectedTab}}">Show Source\
|
||||
ng-href="queries/{{query.id}}/source#{{selectedTab}}">Show Source\
|
||||
</a>\
|
||||
<a ng-show="sourceMode"\
|
||||
ng-href="/queries/{{query.id}}#{{selectedTab}}">Hide Source\
|
||||
ng-href="queries/{{query.id}}#{{selectedTab}}">Hide Source\
|
||||
</a>\
|
||||
</span>'
|
||||
}
|
||||
@@ -50,8 +50,8 @@
|
||||
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");
|
||||
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");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -63,26 +63,97 @@
|
||||
restrict: 'E',
|
||||
scope: {
|
||||
'query': '=',
|
||||
'lock': '='
|
||||
'lock': '=',
|
||||
'schema': '=',
|
||||
'syntax': '='
|
||||
},
|
||||
template: '<textarea\
|
||||
ui-codemirror="editorOptions"\
|
||||
ng-model="query.query">',
|
||||
link: function($scope) {
|
||||
$scope.editorOptions = {
|
||||
mode: 'text/x-sql',
|
||||
template: '<textarea></textarea>',
|
||||
link: {
|
||||
pre: function ($scope, element) {
|
||||
$scope.syntax = $scope.syntax || 'sql';
|
||||
|
||||
var modes = {
|
||||
'sql': 'text/x-sql',
|
||||
'python': 'text/x-python',
|
||||
'json': 'application/json'
|
||||
};
|
||||
|
||||
var textarea = element.children()[0];
|
||||
var editorOptions = {
|
||||
mode: modes[$scope.syntax],
|
||||
lineWrapping: true,
|
||||
lineNumbers: true,
|
||||
readOnly: false,
|
||||
matchBrackets: true,
|
||||
autoCloseBrackets: true
|
||||
};
|
||||
autoCloseBrackets: true,
|
||||
extraKeys: {"Ctrl-Space": "autocomplete"}
|
||||
};
|
||||
|
||||
$scope.$watch('lock', function(locked) {
|
||||
$scope.editorOptions.readOnly = locked ? 'nocursor' : false;
|
||||
});
|
||||
var additionalHints = [];
|
||||
|
||||
CodeMirror.commands.autocomplete = function(cm) {
|
||||
var hinter = function(editor, options) {
|
||||
var hints = CodeMirror.hint.anyword(editor, options);
|
||||
var cur = editor.getCursor(), token = editor.getTokenAt(cur).string;
|
||||
|
||||
hints.list = _.union(hints.list, _.filter(additionalHints, function (h) {
|
||||
return h.search(token) === 0;
|
||||
}));
|
||||
|
||||
return hints;
|
||||
};
|
||||
|
||||
// CodeMirror.showHint(cm, CodeMirror.hint.anyword);
|
||||
CodeMirror.showHint(cm, hinter);
|
||||
};
|
||||
|
||||
var codemirror = CodeMirror.fromTextArea(textarea, editorOptions);
|
||||
|
||||
codemirror.on('change', function(instance) {
|
||||
var newValue = instance.getValue();
|
||||
|
||||
if (newValue !== $scope.query.query) {
|
||||
$scope.$evalAsync(function() {
|
||||
$scope.query.query = newValue;
|
||||
});
|
||||
}
|
||||
|
||||
$('.schema-container').css('height', $('.CodeMirror').css('height'));
|
||||
});
|
||||
|
||||
$scope.$watch('query.query', function () {
|
||||
if ($scope.query.query !== codemirror.getValue()) {
|
||||
codemirror.setValue($scope.query.query);
|
||||
}
|
||||
});
|
||||
|
||||
$scope.$watch('schema', function (schema) {
|
||||
if (schema) {
|
||||
var keywords = [];
|
||||
_.each(schema, function (table) {
|
||||
keywords.push(table.name);
|
||||
_.each(table.columns, function (c) {
|
||||
keywords.push(c);
|
||||
});
|
||||
});
|
||||
|
||||
additionalHints = _.unique(keywords);
|
||||
}
|
||||
|
||||
codemirror.refresh();
|
||||
});
|
||||
|
||||
$scope.$watch('syntax', function(syntax) {
|
||||
codemirror.setOption('mode', modes[syntax]);
|
||||
});
|
||||
|
||||
$scope.$watch('lock', function (locked) {
|
||||
var readOnly = locked ? 'nocursor' : false;
|
||||
codemirror.setOption('readOnly', readOnly);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function queryFormatter($http) {
|
||||
@@ -111,42 +182,102 @@
|
||||
}
|
||||
}
|
||||
|
||||
function queryTimePicker() {
|
||||
return {
|
||||
restrict: 'E',
|
||||
template: '<select ng-disabled="refreshType != \'daily\'" ng-model="hour" ng-change="updateSchedule()" ng-options="c as c for c in hourOptions"></select> :\
|
||||
<select ng-disabled="refreshType != \'daily\'" ng-model="minute" ng-change="updateSchedule()" ng-options="c as c for c in minuteOptions"></select>',
|
||||
link: function($scope) {
|
||||
var padWithZeros = function(size, v) {
|
||||
v = String(v);
|
||||
if (v.length < size) {
|
||||
v = "0" + v;
|
||||
}
|
||||
return v;
|
||||
};
|
||||
|
||||
$scope.hourOptions = _.map(_.range(0, 24), _.partial(padWithZeros, 2));
|
||||
$scope.minuteOptions = _.map(_.range(0, 60, 5), _.partial(padWithZeros, 2));
|
||||
|
||||
if ($scope.query.hasDailySchedule()) {
|
||||
var parts = $scope.query.scheduleInLocalTime().split(':');
|
||||
$scope.minute = parts[1];
|
||||
$scope.hour = parts[0];
|
||||
} else {
|
||||
$scope.minute = "15";
|
||||
$scope.hour = "00";
|
||||
}
|
||||
|
||||
$scope.updateSchedule = function() {
|
||||
var newSchedule = moment().hour($scope.hour).minute($scope.minute).utc().format('HH:mm');
|
||||
if (newSchedule != $scope.query.schedule) {
|
||||
$scope.query.schedule = newSchedule;
|
||||
$scope.saveQuery();
|
||||
}
|
||||
};
|
||||
|
||||
$scope.$watch('refreshType', function() {
|
||||
if ($scope.refreshType == 'daily') {
|
||||
$scope.updateSchedule();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function queryRefreshSelect() {
|
||||
return {
|
||||
restrict: 'E',
|
||||
template: '<select\
|
||||
ng-disabled="!isQueryOwner"\
|
||||
ng-model="query.ttl"\
|
||||
ng-disabled="refreshType != \'periodic\'"\
|
||||
ng-model="query.schedule"\
|
||||
ng-change="saveQuery()"\
|
||||
ng-options="c.value as c.name for c in refreshOptions">\
|
||||
<option value="">No Refresh</option>\
|
||||
</select>',
|
||||
link: function($scope) {
|
||||
$scope.refreshOptions = [
|
||||
{
|
||||
value: -1,
|
||||
name: 'No Refresh'
|
||||
},
|
||||
{
|
||||
value: 60,
|
||||
value: "60",
|
||||
name: 'Every minute'
|
||||
},
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
_.each([5, 10, 15, 30], function(i) {
|
||||
$scope.refreshOptions.push({
|
||||
value: String(i*60),
|
||||
name: "Every " + i + " minutes"
|
||||
})
|
||||
});
|
||||
|
||||
_.each(_.range(1, 13), function (i) {
|
||||
$scope.refreshOptions.push({
|
||||
value: i * 3600,
|
||||
value: String(i * 3600),
|
||||
name: 'Every ' + i + 'h'
|
||||
});
|
||||
})
|
||||
|
||||
$scope.refreshOptions.push({
|
||||
value: 24 * 3600,
|
||||
value: String(24 * 3600),
|
||||
name: 'Every 24h'
|
||||
});
|
||||
$scope.refreshOptions.push({
|
||||
value: 7 * 24 * 3600,
|
||||
value: String(7 * 24 * 3600),
|
||||
name: 'Once a week'
|
||||
});
|
||||
$scope.refreshOptions.push({
|
||||
value: String(30 * 24 * 3600),
|
||||
name: 'Every 30d'
|
||||
});
|
||||
|
||||
$scope.$watch('refreshType', function() {
|
||||
if ($scope.refreshType == 'periodic') {
|
||||
if ($scope.query.hasDailySchedule()) {
|
||||
$scope.query.schedule = null;
|
||||
$scope.saveQuery();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -158,5 +289,6 @@
|
||||
.directive('queryResultLink', queryResultCSVLink)
|
||||
.directive('queryEditor', queryEditor)
|
||||
.directive('queryRefreshSelect', queryRefreshSelect)
|
||||
.directive('queryTimePicker', queryTimePicker)
|
||||
.directive('queryFormatter', ['$http', queryFormatter]);
|
||||
})();
|
||||
})();
|
||||
|
||||
56
rd_ui/app/scripts/embed.js
Normal file
56
rd_ui/app/scripts/embed.js
Normal file
@@ -0,0 +1,56 @@
|
||||
angular.module('redash', [
|
||||
'redash.directives',
|
||||
'redash.admin_controllers',
|
||||
'redash.controllers',
|
||||
'redash.filters',
|
||||
'redash.services',
|
||||
'redash.renderers',
|
||||
'redash.visualization',
|
||||
'plotly',
|
||||
'plotly-chart',
|
||||
'angular-growl',
|
||||
'angularMoment',
|
||||
'ui.bootstrap',
|
||||
'ui.sortable',
|
||||
'smartTable.table',
|
||||
'ngResource',
|
||||
'ngRoute',
|
||||
'ui.select',
|
||||
'naif.base64',
|
||||
'ui.bootstrap.showErrors',
|
||||
'ngSanitize'
|
||||
]).config(['$routeProvider', '$locationProvider', '$compileProvider', 'growlProvider', 'uiSelectConfig',
|
||||
function ($routeProvider, $locationProvider, $compileProvider, growlProvider, uiSelectConfig) {
|
||||
function getQuery(Query, $route) {
|
||||
var query = Query.get({'id': $route.current.params.queryId });
|
||||
return query.$promise;
|
||||
};
|
||||
|
||||
uiSelectConfig.theme = "bootstrap";
|
||||
|
||||
$compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|http|data):/);
|
||||
$locationProvider.html5Mode(true);
|
||||
growlProvider.globalTimeToLive(2000);
|
||||
|
||||
$routeProvider.when('/embed/query/:queryId/visualization/:visualizationId', {
|
||||
templateUrl: '/views/visualization-embed.html',
|
||||
controller: 'EmbedCtrl',
|
||||
reloadOnSearch: false
|
||||
});
|
||||
$routeProvider.otherwise({
|
||||
redirectTo: '/embed'
|
||||
});
|
||||
|
||||
|
||||
}
|
||||
])
|
||||
.controller('EmbedCtrl', ['$scope', function ($scope) {} ])
|
||||
.controller('EmbeddedVisualizationCtrl', ['$scope', 'Query', 'QueryResult',
|
||||
function ($scope, Query, QueryResult) {
|
||||
$scope.embed = true;
|
||||
$scope.visualization = visualization;
|
||||
$scope.query = visualization.query;
|
||||
query = new Query(visualization.query);
|
||||
$scope.queryResult = new QueryResult({query_result:query_result});
|
||||
} ])
|
||||
;
|
||||
@@ -24,13 +24,17 @@ angular.module('redash.filters', []).
|
||||
return durationHumanize;
|
||||
})
|
||||
|
||||
.filter('refreshRateHumanize', function () {
|
||||
return function (ttl) {
|
||||
if (ttl == -1) {
|
||||
.filter('scheduleHumanize', function() {
|
||||
return function (schedule) {
|
||||
if (schedule === null) {
|
||||
return "Never";
|
||||
} else {
|
||||
return "Every " + durationHumanize(ttl);
|
||||
} else if (schedule.match(/\d\d:\d\d/) !== null) {
|
||||
var parts = schedule.split(':');
|
||||
var localTime = moment.utc().hour(parts[0]).minute(parts[1]).local().format('HH:mm');
|
||||
return "Every day at " + localTime;
|
||||
}
|
||||
|
||||
return "Every " + durationHumanize(parseInt(schedule));
|
||||
}
|
||||
})
|
||||
|
||||
@@ -44,6 +48,9 @@ angular.module('redash.filters', []).
|
||||
|
||||
.filter('colWidth', function () {
|
||||
return function (widgetWidth) {
|
||||
if (widgetWidth == 0) {
|
||||
return 0;
|
||||
}
|
||||
if (widgetWidth == 1) {
|
||||
return 6;
|
||||
}
|
||||
@@ -73,7 +80,13 @@ angular.module('redash.filters', []).
|
||||
if (!text) {
|
||||
return "";
|
||||
}
|
||||
return $sce.trustAsHtml(marked(text));
|
||||
|
||||
var html = marked(text);
|
||||
if (clientConfig.allowScriptsInUserInput) {
|
||||
html = $sce.trustAsHtml(html);
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
}])
|
||||
|
||||
@@ -84,4 +97,21 @@ angular.module('redash.filters', []).
|
||||
}
|
||||
return $sce.trustAsHtml(text);
|
||||
}
|
||||
}]);
|
||||
}])
|
||||
|
||||
.filter('remove', function() {
|
||||
return function(items, item) {
|
||||
if (items == undefined)
|
||||
return items;
|
||||
if (item instanceof Array) {
|
||||
var notEquals = function(other) { return item.indexOf(other) == -1; }
|
||||
} else {
|
||||
var notEquals = function(other) { return item != other; }
|
||||
}
|
||||
var filtered = [];
|
||||
for (var i = 0; i < items.length; i++)
|
||||
if (notEquals(items[i]))
|
||||
filtered.push(items[i])
|
||||
return filtered;
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,396 +0,0 @@
|
||||
(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 hasTotalsAlready = _.some(this.series, function (s) {
|
||||
var res = (s.name == 'Total');
|
||||
//if 'Total' already exists - just make it visible
|
||||
if (res) s.setVisible(true, false);
|
||||
return res;
|
||||
})
|
||||
var data = {};
|
||||
_.each(this.series, function (s) {
|
||||
if (s.name != 'Total') s.setVisible(false, false);
|
||||
if (!hasTotalsAlready) {
|
||||
_.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;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (!hasTotalsAlready) {
|
||||
this.addSeries({
|
||||
data: _.values(data),
|
||||
type: 'line',
|
||||
name: 'Total'
|
||||
}, false)
|
||||
}
|
||||
|
||||
this.redraw();
|
||||
}
|
||||
},
|
||||
{
|
||||
text: 'Save Image',
|
||||
onclick: function () {
|
||||
var canvas = document.createElement('canvas');
|
||||
window.canvg(canvas, this.getSVG());
|
||||
var href = canvas.toDataURL('image/png');
|
||||
var a = document.createElement('a');
|
||||
a.href = href;
|
||||
var filenameSuffix = new Date().toISOString().replace(/:/g,'_').replace('Z', '');
|
||||
if (this.title) {
|
||||
filenameSuffix = this.title.text;
|
||||
}
|
||||
a.download = 'redash_charts_'+filenameSuffix+'.png';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
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);
|
||||
};
|
||||
|
||||
// We check either for true or undefined for backward compatibility.
|
||||
var series = scope.series;
|
||||
|
||||
if (chartOptions['sortX'] === true || chartOptions['sortX'] === undefined) {
|
||||
var seriesCopy = [];
|
||||
|
||||
_.each(series, function (s) {
|
||||
// make a copy of series data, so we don't override original.
|
||||
var fieldName = 'x';
|
||||
if (s.data.length > 0 && _.has(s.data[0], 'name')) {
|
||||
fieldName = 'name';
|
||||
};
|
||||
|
||||
var sorted = _.extend({}, s, {data: _.sortBy(s.data, fieldName)});
|
||||
seriesCopy.push(sorted);
|
||||
});
|
||||
|
||||
series = seriesCopy;
|
||||
}
|
||||
|
||||
if (!('xAxis' in chartOptions && 'type' in chartOptions['xAxis'])) {
|
||||
if (series.length > 0 && _.some(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(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(series, function (s) {
|
||||
return _.pluck(s.data, 'x')
|
||||
}));
|
||||
|
||||
_.each(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
|
||||
}
|
||||
});
|
||||
|
||||
s.data = newData;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
scope.chart.counters.color = 0;
|
||||
|
||||
_.each(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();
|
||||
}
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
}]);
|
||||
})();
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,32 @@
|
||||
(function () {
|
||||
var Dashboard = function($resource) {
|
||||
var resource = $resource('/api/dashboards/:slug', {slug: '@slug'}, {
|
||||
var Dashboard = function($resource, $http, Widget) {
|
||||
var transformSingle = function(dashboard) {
|
||||
dashboard.widgets = _.map(dashboard.widgets, function (row) {
|
||||
return _.map(row, function (widget) {
|
||||
return new Widget(widget);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
var transform = $http.defaults.transformResponse.concat(function(data, headers) {
|
||||
if (_.isArray(data)) {
|
||||
_.each(data, transformSingle);
|
||||
} else {
|
||||
transformSingle(data);
|
||||
}
|
||||
return data;
|
||||
});
|
||||
|
||||
var resource = $resource('api/dashboards/:slug', {slug: '@slug'}, {
|
||||
'get': {method: 'GET', transformResponse: transform},
|
||||
'save': {method: 'POST', transformResponse: transform},
|
||||
'query': {method: 'GET', isArray: true, transformResponse: transform},
|
||||
recent: {
|
||||
method: 'get',
|
||||
isArray: true,
|
||||
url: "/api/dashboards/recent"
|
||||
url: "api/dashboards/recent",
|
||||
transformResponse: transform
|
||||
|
||||
}});
|
||||
|
||||
resource.prototype.canEdit = function() {
|
||||
@@ -14,5 +36,5 @@
|
||||
}
|
||||
|
||||
angular.module('redash.services')
|
||||
.factory('Dashboard', ['$resource', Dashboard])
|
||||
.factory('Dashboard', ['$resource', '$http', 'Widget', Dashboard])
|
||||
})();
|
||||
|
||||
@@ -1,7 +1,31 @@
|
||||
(function () {
|
||||
function QueryResultError(errorMessage) {
|
||||
this.errorMessage = errorMessage;
|
||||
}
|
||||
|
||||
QueryResultError.prototype.getError = function() {
|
||||
return this.errorMessage;
|
||||
};
|
||||
|
||||
QueryResultError.prototype.getStatus = function() {
|
||||
return 'failed';
|
||||
};
|
||||
|
||||
QueryResultError.prototype.getData = function() {
|
||||
return null;
|
||||
};
|
||||
|
||||
QueryResultError.prototype.getLog = function() {
|
||||
return null;
|
||||
};
|
||||
|
||||
QueryResultError.prototype.getChartData = function() {
|
||||
return null;
|
||||
};
|
||||
|
||||
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 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);
|
||||
@@ -12,15 +36,17 @@
|
||||
|
||||
var columnTypes = {};
|
||||
|
||||
// TODO: we should stop manipulating incoming data, and switch to relaying on the column type set by the backend.
|
||||
// This logic is prone to errors, and better be removed. Kept for now, for backward compatability.
|
||||
_.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);
|
||||
row[k] = moment.utc(v);
|
||||
columnTypes[k] = 'datetime';
|
||||
} else if (_.isString(v) && v.match(/^\d{4}-\d{2}-\d{2}/)) {
|
||||
row[k] = moment(v);
|
||||
} else if (_.isString(v) && v.match(/^\d{4}-\d{2}-\d{2}$/)) {
|
||||
row[k] = moment.utc(v);
|
||||
columnTypes[k] = 'date';
|
||||
} else if (typeof(v) == 'object' && v !== null) {
|
||||
row[k] = JSON.stringify(v);
|
||||
@@ -30,7 +56,9 @@
|
||||
|
||||
_.each(this.query_result.data.columns, function(column) {
|
||||
if (columnTypes[column.name]) {
|
||||
column.type = columnTypes[column.name];
|
||||
if (column.type == null || column.type == 'string') {
|
||||
column.type = columnTypes[column.name];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -40,7 +68,7 @@
|
||||
} else {
|
||||
this.status = undefined;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function QueryResult(props) {
|
||||
this.deferred = $q.defer();
|
||||
@@ -91,6 +119,14 @@
|
||||
return this.job.error;
|
||||
}
|
||||
|
||||
QueryResult.prototype.getLog = function() {
|
||||
if (!this.query_result.data || !this.query_result.data.log || this.query_result.data.log.length == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.query_result.data.log;
|
||||
}
|
||||
|
||||
QueryResult.prototype.getUpdatedAt = function () {
|
||||
return this.query_result.retrieved_at || this.job.updated_at * 1000.0 || this.updatedAt;
|
||||
}
|
||||
@@ -150,7 +186,22 @@
|
||||
}
|
||||
|
||||
return this.filteredData;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function to add a point into a series
|
||||
*/
|
||||
QueryResult.prototype._addPointToSeries = function (point, seriesCollection, seriesName) {
|
||||
if (seriesCollection[seriesName] == undefined) {
|
||||
seriesCollection[seriesName] = {
|
||||
name: seriesName,
|
||||
type: 'column',
|
||||
data: []
|
||||
};
|
||||
}
|
||||
|
||||
seriesCollection[seriesName]['data'].push(point);
|
||||
};
|
||||
|
||||
QueryResult.prototype.getChartData = function (mapping) {
|
||||
var series = {};
|
||||
@@ -162,8 +213,8 @@
|
||||
var yValues = {};
|
||||
|
||||
_.each(row, function (value, definition) {
|
||||
var name = definition.split("::")[0];
|
||||
var type = definition.split("::")[1];
|
||||
var name = definition.split("::")[0] || definition.split("__")[0];
|
||||
var type = definition.split("::")[1] || definition.split("__")[1];
|
||||
if (mapping) {
|
||||
type = mapping[definition];
|
||||
}
|
||||
@@ -188,31 +239,20 @@
|
||||
seriesName = String(value);
|
||||
}
|
||||
|
||||
if (type == 'multi-filter') {
|
||||
if (type == 'multiFilter' || 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);
|
||||
this._addPointToSeries({'x': xValue, 'y': yValue}, series, seriesName);
|
||||
}.bind(this));
|
||||
}
|
||||
});
|
||||
else {
|
||||
this._addPointToSeries(point, series, seriesName);
|
||||
}
|
||||
}.bind(this));
|
||||
|
||||
return _.values(series);
|
||||
};
|
||||
@@ -236,33 +276,25 @@
|
||||
}
|
||||
|
||||
QueryResult.prototype.getColumnNameWithoutType = function (column) {
|
||||
var parts = column.split('::');
|
||||
var typeSplit;
|
||||
if (column.indexOf("::") != -1) {
|
||||
typeSplit = "::";
|
||||
} else if (column.indexOf("__" != -1)) {
|
||||
typeSplit = "__";
|
||||
} else {
|
||||
return column;
|
||||
}
|
||||
|
||||
var parts = column.split(typeSplit);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -294,16 +326,16 @@
|
||||
|
||||
QueryResult.prototype.prepareFilters = function () {
|
||||
var filters = [];
|
||||
var filterTypes = ['filter', 'multi-filter'];
|
||||
var filterTypes = ['filter', 'multi-filter', 'multiFilter'];
|
||||
_.each(this.getColumnNames(), function (col) {
|
||||
var type = col.split('::')[1]
|
||||
var type = col.split('::')[1] || col.split('__')[1];
|
||||
if (_.contains(filterTypes, type)) {
|
||||
// filter found
|
||||
var filter = {
|
||||
name: col,
|
||||
friendlyName: this.getColumnFriendlyName(col),
|
||||
values: [],
|
||||
multiple: (type=='multi-filter')
|
||||
multiple: (type=='multiFilter') || (type=='multi-filter')
|
||||
}
|
||||
filters.push(filter);
|
||||
}
|
||||
@@ -325,7 +357,7 @@
|
||||
this.filters = filters;
|
||||
}
|
||||
|
||||
var refreshStatus = function (queryResult, query, ttl) {
|
||||
var refreshStatus = function (queryResult, query) {
|
||||
Job.get({'id': queryResult.job.id}, function (response) {
|
||||
queryResult.update(response);
|
||||
|
||||
@@ -335,7 +367,7 @@
|
||||
});
|
||||
} else if (queryResult.getStatus() != "failed") {
|
||||
$timeout(function () {
|
||||
refreshStatus(queryResult, query, ttl);
|
||||
refreshStatus(queryResult, query);
|
||||
}, 3000);
|
||||
}
|
||||
})
|
||||
@@ -355,14 +387,23 @@
|
||||
return this.deferred.promise;
|
||||
}
|
||||
|
||||
QueryResult.get = function (data_source_id, query, ttl) {
|
||||
QueryResult.get = function (data_source_id, query, maxAge, queryId) {
|
||||
var queryResult = new QueryResult();
|
||||
|
||||
QueryResultResource.post({'data_source_id': data_source_id, 'query': query, 'ttl': ttl}, function (response) {
|
||||
var params = {'data_source_id': data_source_id, 'query': query, 'max_age': maxAge};
|
||||
if (queryId !== undefined) {
|
||||
params['query_id'] = queryId;
|
||||
};
|
||||
|
||||
QueryResultResource.post(params, function (response) {
|
||||
queryResult.update(response);
|
||||
|
||||
if ('job' in response) {
|
||||
refreshStatus(queryResult, query, ttl);
|
||||
refreshStatus(queryResult, query);
|
||||
}
|
||||
}, function(error) {
|
||||
if (error.status === 403) {
|
||||
queryResult.update(error.data);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -373,24 +414,24 @@
|
||||
};
|
||||
|
||||
var Query = function ($resource, QueryResult, DataSource) {
|
||||
var Query = $resource('/api/queries/:id', {id: '@id'},
|
||||
var Query = $resource('api/queries/:id', {id: '@id'},
|
||||
{
|
||||
search: {
|
||||
method: 'get',
|
||||
isArray: true,
|
||||
url: "/api/queries/search"
|
||||
url: "api/queries/search"
|
||||
},
|
||||
recent: {
|
||||
method: 'get',
|
||||
isArray: true,
|
||||
url: "/api/queries/recent"
|
||||
url: "api/queries/recent"
|
||||
}});
|
||||
|
||||
Query.newQuery = function () {
|
||||
return new Query({
|
||||
query: "",
|
||||
name: "New Query",
|
||||
ttl: -1,
|
||||
schedule: null,
|
||||
user: currentUser
|
||||
});
|
||||
};
|
||||
@@ -414,11 +455,23 @@
|
||||
return '/queries/' + this.id + '/source';
|
||||
};
|
||||
|
||||
Query.prototype.getQueryResult = function (ttl, parameters) {
|
||||
if (ttl == undefined) {
|
||||
ttl = this.ttl;
|
||||
}
|
||||
Query.prototype.isNew = function() {
|
||||
return this.id === undefined;
|
||||
};
|
||||
|
||||
Query.prototype.hasDailySchedule = function() {
|
||||
return (this.schedule && this.schedule.match(/\d\d:\d\d/) !== null);
|
||||
};
|
||||
|
||||
Query.prototype.scheduleInLocalTime = function() {
|
||||
var parts = this.schedule.split(':');
|
||||
return moment.utc().hour(parts[0]).minute(parts[1]).local().format('HH:mm');
|
||||
};
|
||||
|
||||
Query.prototype.getQueryResult = function (maxAge, parameters) {
|
||||
if (!this.query) {
|
||||
return;
|
||||
}
|
||||
var queryText = this.query;
|
||||
|
||||
var queryParameters = this.getParameters();
|
||||
@@ -443,16 +496,18 @@
|
||||
this.latest_query_data_id = null;
|
||||
}
|
||||
|
||||
if (this.latest_query_data && ttl != 0) {
|
||||
if (this.latest_query_data && maxAge != 0) {
|
||||
if (!this.queryResult) {
|
||||
this.queryResult = new QueryResult({'query_result': this.latest_query_data});
|
||||
}
|
||||
} else if (this.latest_query_data_id && ttl != 0) {
|
||||
} else if (this.latest_query_data_id && maxAge != 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, queryText, ttl);
|
||||
this.queryResult = QueryResult.get(this.data_source_id, queryText, maxAge, this.id);
|
||||
} else {
|
||||
return new QueryResultError("Please select data source to run this query.");
|
||||
}
|
||||
|
||||
return this.queryResult;
|
||||
@@ -488,13 +543,83 @@
|
||||
|
||||
|
||||
var DataSource = function ($resource) {
|
||||
var DataSourceResource = $resource('/api/data_sources/:id', {id: '@id'}, {'get': {'method': 'GET', 'cache': true, 'isArray': true}});
|
||||
var actions = {
|
||||
'get': {'method': 'GET', 'cache': false, 'isArray': false},
|
||||
'query': {'method': 'GET', 'cache': false, 'isArray': true},
|
||||
'getSchema': {'method': 'GET', 'cache': true, 'isArray': true, 'url': 'api/data_sources/:id/schema'}
|
||||
};
|
||||
|
||||
var DataSourceResource = $resource('api/data_sources/:id', {id: '@id'}, actions);
|
||||
|
||||
return DataSourceResource;
|
||||
}
|
||||
};
|
||||
|
||||
var User = function ($resource, $http) {
|
||||
var transformSingle = function(user) {
|
||||
if (user.groups !== undefined) {
|
||||
user.admin = user.groups.indexOf("admin") != -1;
|
||||
}
|
||||
};
|
||||
|
||||
var transform = $http.defaults.transformResponse.concat(function(data, headers) {
|
||||
if (_.isArray(data)) {
|
||||
_.each(data, transformSingle);
|
||||
} else {
|
||||
transformSingle(data);
|
||||
}
|
||||
return data;
|
||||
});
|
||||
|
||||
var actions = {
|
||||
'get': {method: 'GET', transformResponse: transform},
|
||||
'save': {method: 'POST', transformResponse: transform},
|
||||
'query': {method: 'GET', isArray: true, transformResponse: transform},
|
||||
'delete': {method: 'DELETE', transformResponse: transform}
|
||||
};
|
||||
|
||||
var UserResource = $resource('api/users/:id', {id: '@id'}, actions);
|
||||
|
||||
return UserResource;
|
||||
};
|
||||
|
||||
var Group = function ($resource) {
|
||||
var actions = {
|
||||
'get': {'method': 'GET', 'cache': false, 'isArray': false},
|
||||
'query': {'method': 'GET', 'cache': false, 'isArray': true},
|
||||
'members': {'method': 'GET', 'cache': true, 'isArray': true, 'url': 'api/groups/:id/members'},
|
||||
'dataSources': {'method': 'GET', 'cache': true, 'isArray': true, 'url': 'api/groups/:id/data_sources'}
|
||||
};
|
||||
var resource = $resource('api/groups/:id', {id: '@id'}, actions);
|
||||
return resource;
|
||||
};
|
||||
|
||||
var AlertSubscription = function ($resource) {
|
||||
var resource = $resource('api/alerts/:alertId/subscriptions/:userId', {alertId: '@alert_id', userId: '@user.id'});
|
||||
return resource;
|
||||
};
|
||||
|
||||
var Alert = function ($resource, $http) {
|
||||
var actions = {
|
||||
save: {
|
||||
method: 'POST',
|
||||
transformRequest: [function(data) {
|
||||
var newData = _.extend({}, data);
|
||||
if (newData.query_id === undefined) {
|
||||
newData.query_id = newData.query.id;
|
||||
delete newData.query;
|
||||
}
|
||||
|
||||
return newData;
|
||||
}].concat($http.defaults.transformRequest)
|
||||
}
|
||||
};
|
||||
var resource = $resource('api/alerts/:id', {id: '@id'}, actions);
|
||||
|
||||
return resource;
|
||||
};
|
||||
|
||||
var Widget = function ($resource, Query) {
|
||||
var WidgetResource = $resource('/api/widgets/:id', {id: '@id'});
|
||||
var WidgetResource = $resource('api/widgets/:id', {id: '@id'});
|
||||
|
||||
WidgetResource.prototype.getQuery = function () {
|
||||
if (!this.query && this.visualization) {
|
||||
@@ -518,5 +643,9 @@
|
||||
.factory('QueryResult', ['$resource', '$timeout', '$q', QueryResult])
|
||||
.factory('Query', ['$resource', 'QueryResult', 'DataSource', Query])
|
||||
.factory('DataSource', ['$resource', DataSource])
|
||||
.factory('Widget', ['$resource', 'Query', Widget]);
|
||||
.factory('Alert', ['$resource', '$http', Alert])
|
||||
.factory('AlertSubscription', ['$resource', AlertSubscription])
|
||||
.factory('Widget', ['$resource', 'Query', Widget])
|
||||
.factory('User', ['$resource', '$http', User])
|
||||
.factory('Group', ['$resource', Group]);
|
||||
})();
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
var events = this.events;
|
||||
this.events = [];
|
||||
|
||||
$http.post('/api/events', events);
|
||||
$http.post('api/events', events);
|
||||
|
||||
}, 1000);
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user