mirror of
https://github.com/getredash/redash.git
synced 2025-12-25 01:03:20 -05:00
Compare commits
627 Commits
v0.10.1.b1
...
v0.12.0.b2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca39892c64 | ||
|
|
2d7a497073 | ||
|
|
788c16ce37 | ||
|
|
2de4aa2a0c | ||
|
|
10cbb7fd52 | ||
|
|
0cbbe7095d | ||
|
|
c88dafa4ad | ||
|
|
48a79fe996 | ||
|
|
b70a24f6b4 | ||
|
|
d1b82694a6 | ||
|
|
db1a941459 | ||
|
|
e22706692a | ||
|
|
c06aeae84f | ||
|
|
e0617d9ad7 | ||
|
|
29a2fb1931 | ||
|
|
e21799a754 | ||
|
|
d6febb0cb4 | ||
|
|
61fe16e18e | ||
|
|
2184f53277 | ||
|
|
733f245e36 | ||
|
|
6c4294b64d | ||
|
|
2f090435a5 | ||
|
|
d9bad96e8e | ||
|
|
b7a5d95bb8 | ||
|
|
ab85e43e58 | ||
|
|
96553ad942 | ||
|
|
c1847fbc12 | ||
|
|
dc345aa363 | ||
|
|
002f794f2a | ||
|
|
d2c64c6da2 | ||
|
|
e2595e7540 | ||
|
|
aa5d14ed02 | ||
|
|
4ba7aa1fc0 | ||
|
|
fd9dc4b4e8 | ||
|
|
c57c765688 | ||
|
|
52b87efb73 | ||
|
|
36d01a2029 | ||
|
|
2592959550 | ||
|
|
6c5dd09a78 | ||
|
|
9cb9bdb515 | ||
|
|
df17759ab4 | ||
|
|
028393b229 | ||
|
|
8245a667ef | ||
|
|
6218421266 | ||
|
|
f34471ec10 | ||
|
|
40cc592591 | ||
|
|
bb96702ae6 | ||
|
|
8b091129ed | ||
|
|
edea6f3a05 | ||
|
|
c51477ac93 | ||
|
|
9f3bbfee13 | ||
|
|
7ba5a2062a | ||
|
|
00a77f8d3a | ||
|
|
b9ab9135d0 | ||
|
|
19e5a0af86 | ||
|
|
b748eb14f4 | ||
|
|
60a79cbe08 | ||
|
|
c0c4f453f2 | ||
|
|
e0672f4c4d | ||
|
|
6b540e03fc | ||
|
|
95dca53b1e | ||
|
|
91a46ea1bb | ||
|
|
903ba0c1e0 | ||
|
|
2a688200be | ||
|
|
37dff5f0a4 | ||
|
|
6397b8ca1f | ||
|
|
360028c01f | ||
|
|
6a42daffe2 | ||
|
|
7ee41d41b5 | ||
|
|
c138d0592a | ||
|
|
3db0eea921 | ||
|
|
9ce211bf09 | ||
|
|
5610ce1721 | ||
|
|
67528eeb73 | ||
|
|
880627c69c | ||
|
|
ae2cd5363f | ||
|
|
b0ecd0e9a0 | ||
|
|
23c605b149 | ||
|
|
464b8368bf | ||
|
|
cb0ea7b63e | ||
|
|
ef07388d2a | ||
|
|
8464d8c64a | ||
|
|
37a02bfe37 | ||
|
|
41f7791c87 | ||
|
|
9041ccabd3 | ||
|
|
d4a1a5b239 | ||
|
|
cf7ed8fae7 | ||
|
|
f63d43c3cf | ||
|
|
aec38614c0 | ||
|
|
459a25bedd | ||
|
|
14e024bca8 | ||
|
|
fc8985f689 | ||
|
|
cecc1a9462 | ||
|
|
fe6497dfe7 | ||
|
|
79df2b8d22 | ||
|
|
ef6a543850 | ||
|
|
c17a6956dc | ||
|
|
6775f01684 | ||
|
|
3a4754303d | ||
|
|
f9824675f1 | ||
|
|
42ae78a017 | ||
|
|
f56cbf051c | ||
|
|
ef80fb1d1a | ||
|
|
b92f22c36e | ||
|
|
fde0ba1503 | ||
|
|
c8b62755d0 | ||
|
|
7de2d6c101 | ||
|
|
8cc4e2bee7 | ||
|
|
7b0f5a195e | ||
|
|
0d8ee9ced7 | ||
|
|
838c211198 | ||
|
|
9c3baed230 | ||
|
|
435b49fa9c | ||
|
|
707df82b40 | ||
|
|
8116c6140f | ||
|
|
34543e67f7 | ||
|
|
afe5cae2a9 | ||
|
|
94a0bddb3d | ||
|
|
9786063dbb | ||
|
|
025e9d2710 | ||
|
|
a9562d361f | ||
|
|
97ad716d5a | ||
|
|
95367abc91 | ||
|
|
f7af1fa82a | ||
|
|
5321948e46 | ||
|
|
df437999ca | ||
|
|
f4b87e76a3 | ||
|
|
f0d0d60dc1 | ||
|
|
26bd08bb2b | ||
|
|
e5146c3755 | ||
|
|
f12d47752c | ||
|
|
7683402741 | ||
|
|
ba354ce65a | ||
|
|
f892a3c70a | ||
|
|
cc1dae8eed | ||
|
|
0436c3b5b7 | ||
|
|
e810b36496 | ||
|
|
50ece739d9 | ||
|
|
2d2df5c9e0 | ||
|
|
d54e9125d9 | ||
|
|
78bc42e65c | ||
|
|
186537d849 | ||
|
|
a729601dff | ||
|
|
07af792943 | ||
|
|
c14d119fe7 | ||
|
|
1c4225beff | ||
|
|
53b710ee7b | ||
|
|
04398ff909 | ||
|
|
ce77f452c7 | ||
|
|
7a855d1e0a | ||
|
|
0235d37005 | ||
|
|
5df4e7eb78 | ||
|
|
015b1dc8fd | ||
|
|
8e9e288a1d | ||
|
|
2135dfd2e5 | ||
|
|
08676a3d0b | ||
|
|
1ac3119648 | ||
|
|
39aaa2fd94 | ||
|
|
85fe74f3db | ||
|
|
7cbf350b73 | ||
|
|
b22191b789 | ||
|
|
23ba98bc94 | ||
|
|
66f8922d5b | ||
|
|
3283116518 | ||
|
|
2565af604e | ||
|
|
0d944794e4 | ||
|
|
7cc22c71a1 | ||
|
|
4d47583a94 | ||
|
|
49e788a1aa | ||
|
|
7145aa2086 | ||
|
|
d1a3ed312a | ||
|
|
2db4b67505 | ||
|
|
39091e006a | ||
|
|
229ca6cb52 | ||
|
|
2ac64a7d08 | ||
|
|
00acaa214b | ||
|
|
462faea52d | ||
|
|
d6dd95db31 | ||
|
|
a8fa68a563 | ||
|
|
931a1f3379 | ||
|
|
0952cf8178 | ||
|
|
0eab12880f | ||
|
|
39b4f9af22 | ||
|
|
1049d46a20 | ||
|
|
73e1837469 | ||
|
|
ca1ca9b451 | ||
|
|
fb30a8217c | ||
|
|
30451bc0d9 | ||
|
|
cd2e9276fb | ||
|
|
fc00e61d49 | ||
|
|
6a973f31b3 | ||
|
|
a562ce748d | ||
|
|
4462afc670 | ||
|
|
ad5e4f46d6 | ||
|
|
d48192cb0f | ||
|
|
1e85caa6c1 | ||
|
|
649e0bc53f | ||
|
|
d72a19894a | ||
|
|
11a2b55c08 | ||
|
|
9cd9958827 | ||
|
|
beb89ec657 | ||
|
|
eb47d88b33 | ||
|
|
0530b5fe1e | ||
|
|
e8eb840d32 | ||
|
|
8cf0252b07 | ||
|
|
0b79fb833e | ||
|
|
5096e4ed79 | ||
|
|
83ffd915c8 | ||
|
|
e8582ec100 | ||
|
|
6829192854 | ||
|
|
41f99f54cf | ||
|
|
3b6017495e | ||
|
|
808fdd4507 | ||
|
|
aefd2fde0a | ||
|
|
af56f59255 | ||
|
|
dfb1a204e2 | ||
|
|
b711e5c4a2 | ||
|
|
8c1056cc4f | ||
|
|
9d6b3f14a5 | ||
|
|
03217dd7ea | ||
|
|
ff9e844204 | ||
|
|
01eb099c3d | ||
|
|
2b25f2e80a | ||
|
|
6d686f03a3 | ||
|
|
de222429a1 | ||
|
|
a3cf92ecf6 | ||
|
|
37b40164ab | ||
|
|
e155191c93 | ||
|
|
b20b263ed1 | ||
|
|
8cfbf8b8bb | ||
|
|
e42f93fcce | ||
|
|
b9d1e43a8e | ||
|
|
6cbc39cbe2 | ||
|
|
09a848f524 | ||
|
|
21a9b4b03e | ||
|
|
d9623faf8c | ||
|
|
ef4699aca7 | ||
|
|
43075f741d | ||
|
|
ddd91e37db | ||
|
|
4caf2e309d | ||
|
|
0eb5a7d203 | ||
|
|
170bd65237 | ||
|
|
2739f04f1e | ||
|
|
4a8a67f6f4 | ||
|
|
4710c4193e | ||
|
|
2e5ec26be9 | ||
|
|
cfbb466f92 | ||
|
|
bc3a5ab04c | ||
|
|
db4aec22f6 | ||
|
|
d22f0d44b6 | ||
|
|
03837c0659 | ||
|
|
2eeb94765d | ||
|
|
9fef335315 | ||
|
|
17726dbcb9 | ||
|
|
10b398e8e6 | ||
|
|
2b5e34099f | ||
|
|
e05a63db9a | ||
|
|
9a980759d3 | ||
|
|
8ce02d3003 | ||
|
|
6202d0963d | ||
|
|
d41b84eb2e | ||
|
|
e7d6ac07c9 | ||
|
|
ba30577601 | ||
|
|
7cce9d5d6e | ||
|
|
b308e0275c | ||
|
|
0319acc7ca | ||
|
|
93aac14c87 | ||
|
|
ca6ee5e04f | ||
|
|
2aaf5dd2f0 | ||
|
|
10f5ecdb00 | ||
|
|
6ba76debf0 | ||
|
|
b8eca28e20 | ||
|
|
490928d474 | ||
|
|
19530f4132 | ||
|
|
2e1dce5961 | ||
|
|
d5b374c540 | ||
|
|
94ce4b7b6e | ||
|
|
dfb7cc1934 | ||
|
|
37271c746c | ||
|
|
986dc686bb | ||
|
|
bd5039ad95 | ||
|
|
37873196ec | ||
|
|
87d77d4d27 | ||
|
|
eee2e7c833 | ||
|
|
dfb92dbb4e | ||
|
|
5baf72a01e | ||
|
|
b78100355c | ||
|
|
b750843865 | ||
|
|
a69ee0cfe9 | ||
|
|
0b928e6a9b | ||
|
|
a411af2512 | ||
|
|
7843d2ee84 | ||
|
|
1d693ad220 | ||
|
|
88d61e8faa | ||
|
|
058b6bc37c | ||
|
|
8d8af7386c | ||
|
|
91ca74b46c | ||
|
|
1e186d10a8 | ||
|
|
14dea68e25 | ||
|
|
12896ed039 | ||
|
|
549fe8a465 | ||
|
|
eafe0dbe34 | ||
|
|
7598048317 | ||
|
|
17fa957a91 | ||
|
|
ae3af64c09 | ||
|
|
a02eddabb5 | ||
|
|
ca7d8699c8 | ||
|
|
3dbb5a6bfc | ||
|
|
50419f3d8c | ||
|
|
0e70188cb4 | ||
|
|
77ce9b1d58 | ||
|
|
20206048af | ||
|
|
a7cc1eee5f | ||
|
|
295ca92e44 | ||
|
|
1995fe4258 | ||
|
|
5b20fe21aa | ||
|
|
738cd1d69d | ||
|
|
57651f177b | ||
|
|
061783313a | ||
|
|
218937b175 | ||
|
|
c43357cc77 | ||
|
|
42e7a41fcc | ||
|
|
52cbb42aaf | ||
|
|
767fc3644a | ||
|
|
9a6d2d7c62 | ||
|
|
a9fac34560 | ||
|
|
6a9467451a | ||
|
|
56ffec1be7 | ||
|
|
5d43cbe67f | ||
|
|
e0e5dd3dd8 | ||
|
|
2dac682e8e | ||
|
|
f524dda88b | ||
|
|
84d0c2294c | ||
|
|
e0485dec56 | ||
|
|
fb523725f6 | ||
|
|
1dd736d9b5 | ||
|
|
600afa5c82 | ||
|
|
78f65b145a | ||
|
|
ea28e71170 | ||
|
|
9193fed393 | ||
|
|
57ee9fd18b | ||
|
|
3f1d48b1f2 | ||
|
|
7844b908de | ||
|
|
28ffff8930 | ||
|
|
21283e2e83 | ||
|
|
d4bfbc2c57 | ||
|
|
49d8a99bc4 | ||
|
|
dae2907ca3 | ||
|
|
dd45fe04ee | ||
|
|
74021c2d5a | ||
|
|
87d7d9cb5d | ||
|
|
697e377bec | ||
|
|
99906c1d0d | ||
|
|
b1937aaab2 | ||
|
|
cd449183bf | ||
|
|
dd0d29467e | ||
|
|
ff49d25963 | ||
|
|
5a1f4d9144 | ||
|
|
679e44c874 | ||
|
|
eaf127da71 | ||
|
|
628122053b | ||
|
|
8a5a71421d | ||
|
|
906365f011 | ||
|
|
bab1029c9d | ||
|
|
d263688da4 | ||
|
|
7d10edd32c | ||
|
|
a34357d222 | ||
|
|
95fa6849b3 | ||
|
|
4496a004e8 | ||
|
|
6905340c2d | ||
|
|
3ec113e8d0 | ||
|
|
bba801f9d5 | ||
|
|
b41041014f | ||
|
|
31edf9cf80 | ||
|
|
522e07ac95 | ||
|
|
837073144f | ||
|
|
9895e28a3f | ||
|
|
ae9e295d2f | ||
|
|
7681d3ee84 | ||
|
|
458f5eb032 | ||
|
|
ce81d69f91 | ||
|
|
0456caf798 | ||
|
|
bb2574ef0b | ||
|
|
bcd3670282 | ||
|
|
f7e556969a | ||
|
|
dd759fe4b0 | ||
|
|
988b301f65 | ||
|
|
923b3b18e4 | ||
|
|
95c47138ab | ||
|
|
cfbffe0cce | ||
|
|
991fe618b7 | ||
|
|
0538fe401b | ||
|
|
31e7375a30 | ||
|
|
8808e38de9 | ||
|
|
0314313285 | ||
|
|
9c0d1da7f9 | ||
|
|
904ea9f90a | ||
|
|
6bb09d8446 | ||
|
|
d5e5b2438b | ||
|
|
dbd48e15bc | ||
|
|
21fdd6b69d | ||
|
|
a666adeaa7 | ||
|
|
15361cc81c | ||
|
|
704a167c74 | ||
|
|
df2c8d83b0 | ||
|
|
7445972c10 | ||
|
|
1933995a28 | ||
|
|
8df822eee2 | ||
|
|
227fe9b44a | ||
|
|
5d0ed02caa | ||
|
|
392627d6d6 | ||
|
|
72d02e9e9d | ||
|
|
902ce24f6f | ||
|
|
c5bfbbaef7 | ||
|
|
58a9bedb64 | ||
|
|
a03f5f88fb | ||
|
|
ec50cf97a9 | ||
|
|
a66e182f73 | ||
|
|
96dd811607 | ||
|
|
409200188e | ||
|
|
ad65391914 | ||
|
|
203f6afa09 | ||
|
|
2b710420ab | ||
|
|
01116f41ed | ||
|
|
87e25f2107 | ||
|
|
c495250a54 | ||
|
|
c01d266030 | ||
|
|
8515ac25bc | ||
|
|
23988a72aa | ||
|
|
6bc0e7a716 | ||
|
|
2c2ff0d252 | ||
|
|
69cefee0d4 | ||
|
|
02c065751a | ||
|
|
aed65f4bad | ||
|
|
6bb2716fe3 | ||
|
|
efaeb08178 | ||
|
|
e18a073128 | ||
|
|
f21276ec06 | ||
|
|
b0c0582e41 | ||
|
|
9ad85091ed | ||
|
|
2d2fb69b7b | ||
|
|
3ce27b9652 | ||
|
|
da4db94cf8 | ||
|
|
4cbc79a7aa | ||
|
|
4fabaaea8a | ||
|
|
a7af596da0 | ||
|
|
df637e3f6b | ||
|
|
68465b0c60 | ||
|
|
86565402fa | ||
|
|
c2e3637dce | ||
|
|
52558043ee | ||
|
|
a045d7ddf7 | ||
|
|
c107c94a27 | ||
|
|
790128ce77 | ||
|
|
abc790ce41 | ||
|
|
f2643521f7 | ||
|
|
0d897e6878 | ||
|
|
4ec473cf5e | ||
|
|
0c7f0c25a8 | ||
|
|
8c21e9149d | ||
|
|
7159f0beb0 | ||
|
|
095e7596b5 | ||
|
|
31013836ea | ||
|
|
91e99c42cd | ||
|
|
b67f412f58 | ||
|
|
c1bf9dc67d | ||
|
|
65635ec703 | ||
|
|
ceaa00e448 | ||
|
|
679b0a3125 | ||
|
|
fe81dbd3a2 | ||
|
|
1409907ef1 | ||
|
|
cbbfc4e931 | ||
|
|
1ca5262fa8 | ||
|
|
429b76f5a7 | ||
|
|
8b73a2b135 | ||
|
|
eed5485080 | ||
|
|
daa6c1cd6f | ||
|
|
68dc3b033c | ||
|
|
2e88e7f396 | ||
|
|
cd06d276e4 | ||
|
|
437f589fde | ||
|
|
1fbeb5d2a5 | ||
|
|
df1e72ca01 | ||
|
|
fcc656e04e | ||
|
|
a0b97c1fc9 | ||
|
|
4d6599e0ea | ||
|
|
c75054b320 | ||
|
|
011ca74338 | ||
|
|
434615a1be | ||
|
|
2bc0b276b5 | ||
|
|
e942486ed7 | ||
|
|
9eff7ef8c9 | ||
|
|
34b305d232 | ||
|
|
f0d97bc5d1 | ||
|
|
f64622db77 | ||
|
|
8030baa6a5 | ||
|
|
3d82b702b3 | ||
|
|
ad8676df2e | ||
|
|
ea031e9a98 | ||
|
|
9cfebedec9 | ||
|
|
772d263827 | ||
|
|
8c455c8a1c | ||
|
|
857caab20e | ||
|
|
59f8af2c44 | ||
|
|
9538ee7c31 | ||
|
|
e8312185dc | ||
|
|
07d2b5ba42 | ||
|
|
f8120284d5 | ||
|
|
5b654fd1c8 | ||
|
|
6edb0ca8ec | ||
|
|
3f208c03fd | ||
|
|
ef0de1414d | ||
|
|
214aa3b799 | ||
|
|
64d7538040 | ||
|
|
69177752bc | ||
|
|
d83c6c42dd | ||
|
|
2043834ae9 | ||
|
|
d6f4af448c | ||
|
|
43b425f91c | ||
|
|
17427cf47b | ||
|
|
b5be5a8fa4 | ||
|
|
14fcf01751 | ||
|
|
09848d65a1 | ||
|
|
0d897ea959 | ||
|
|
e88d4c3d27 | ||
|
|
82f0b4c386 | ||
|
|
3037c4f90d | ||
|
|
8900d02c95 | ||
|
|
c1c2db4a73 | ||
|
|
574d8a18ae | ||
|
|
82872db111 | ||
|
|
3f90dd9247 | ||
|
|
b2e2277d0b | ||
|
|
e20a00566a | ||
|
|
e10ecd2dad | ||
|
|
6e0dd2b9a3 | ||
|
|
0bb3fb9c40 | ||
|
|
1a1160eb76 | ||
|
|
d4ae97aab2 | ||
|
|
8bc42c8ad9 | ||
|
|
6c5865bd3b | ||
|
|
701035fabd | ||
|
|
31aee1b6b9 | ||
|
|
367ea859e4 | ||
|
|
d79d3da955 | ||
|
|
6c822d1e4b | ||
|
|
ad85b9a62c | ||
|
|
b5a4a6b880 | ||
|
|
1828de20b0 | ||
|
|
48c85645c6 | ||
|
|
ed45dcb01d | ||
|
|
d4ff7482ad | ||
|
|
90f0b3b49a | ||
|
|
f8efb2d7ea | ||
|
|
d2ba0cb6cf | ||
|
|
cfb852e9c5 | ||
|
|
d5c6e57c62 | ||
|
|
2924d4fce6 | ||
|
|
e602b8cf2b | ||
|
|
0b806e2e7d | ||
|
|
c3c302e11e | ||
|
|
aa837ed09b | ||
|
|
f07e7273c1 | ||
|
|
9b6f555d76 | ||
|
|
e069374232 | ||
|
|
c496df3b87 | ||
|
|
2ee0065102 | ||
|
|
c0ffea7083 | ||
|
|
fec0d5fecc | ||
|
|
83a03a22b1 | ||
|
|
8b5dc8ef68 | ||
|
|
f3a274a5c0 | ||
|
|
386d6efdaa | ||
|
|
e415189017 | ||
|
|
b066ce4b74 | ||
|
|
056ae4f63e | ||
|
|
6d495d2f2c | ||
|
|
960c416fcb | ||
|
|
f7322a413f | ||
|
|
d9cc063be2 | ||
|
|
8fa6fdb0d5 | ||
|
|
7016477700 | ||
|
|
0bb722df5d | ||
|
|
b3844d3643 | ||
|
|
e32bfe3db7 | ||
|
|
4591eff557 | ||
|
|
7062873cd1 | ||
|
|
203cf6e28b | ||
|
|
9e23cc2bf2 | ||
|
|
c5d92b4e7e | ||
|
|
41dfcd8cbf | ||
|
|
1fa701c136 | ||
|
|
303e158eb1 | ||
|
|
19aaa938d8 | ||
|
|
c850acb3b9 | ||
|
|
4bcb705a2a | ||
|
|
1c04f3cc29 | ||
|
|
ee29f07802 | ||
|
|
df2067eec1 | ||
|
|
601010e44e | ||
|
|
6c3b713b3d | ||
|
|
faf2f7dede | ||
|
|
bf880a834b | ||
|
|
ce6ceac5c4 | ||
|
|
70b4f9d447 | ||
|
|
3838b03417 | ||
|
|
a11fa2717d | ||
|
|
becf315e66 | ||
|
|
04eb37a7f2 | ||
|
|
e91610f4b4 | ||
|
|
63786c98df | ||
|
|
54f3df6988 | ||
|
|
bb3874e631 | ||
|
|
eef18510d5 | ||
|
|
a3c0917d85 | ||
|
|
ed7f9ea5f0 | ||
|
|
82b7146216 | ||
|
|
3cfbb9855b | ||
|
|
4938f8e013 | ||
|
|
a43761da39 | ||
|
|
a3703b2058 | ||
|
|
f2d5d52310 | ||
|
|
eed2a41816 | ||
|
|
16c0df4117 | ||
|
|
3844483776 | ||
|
|
53f8f1de3b | ||
|
|
3ac7f02aea |
24
.github/ISSUE_TEMPLATE.md
vendored
Normal file
24
.github/ISSUE_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
Welcome to Redash's GitHub repo! 👋🎉
|
||||
|
||||
Do you need help or have a question? Checkout the Support category in our discussion forum: https://discuss.redash.io/c/support.
|
||||
|
||||
Got an idea for a new feature? Check if it isn't on the roadmap already: http://bit.ly/redash-roadmap and start a new discussion in the features category: https://discuss.redash.io/c/feature-requests 🌟.
|
||||
|
||||
Found a bug? Please fill out the sections below... thank you 👍
|
||||
|
||||
### Issue Summary
|
||||
|
||||
A summary of the issue and the browser/OS environment in which it occurs.
|
||||
|
||||
### Steps to Reproduce
|
||||
|
||||
1. This is the first step
|
||||
2. This is the second step, etc.
|
||||
|
||||
Any other info e.g. Why do you consider this to be a bug? What did you expect to happen instead?
|
||||
|
||||
### Technical details:
|
||||
|
||||
* Redash Version:
|
||||
* Browser/OS:
|
||||
* How did you install Redash:
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -22,3 +22,9 @@ dump.rdb
|
||||
|
||||
# Docker related
|
||||
docker-compose.yml
|
||||
|
||||
node_modules
|
||||
.tmp
|
||||
.sass-cache
|
||||
rd_ui/app/bower_components
|
||||
npm-debug.log
|
||||
|
||||
196
CHANGELOG.md
Normal file
196
CHANGELOG.md
Normal file
@@ -0,0 +1,196 @@
|
||||
# Change Log
|
||||
|
||||
## v0.12.0 - 2016-11-20
|
||||
|
||||
### Added
|
||||
|
||||
61fe16e #1374: Add: allow '*' in REDASH_CORS_ACCESS_CONTROL_ALLOW_ORIGIN (Allen Short)
|
||||
2f09043 #1113: Add: share modify/access permissions for queries and dashboard (whummer)
|
||||
3db0eea #1341: Add: support for specifying SAML nameid-format (zoetrope)
|
||||
b0ecd0e #1343: Add: support for local SAML metadata file (zoetrope)
|
||||
0235d37 #1335: Add: allow changing alert email subject. (Arik Fraimovich)
|
||||
2135dfd #1333: Add: control over y axis min/max values (Arik Fraimovich)
|
||||
49e788a #1328: Add: support for snapshot generation service (Arik Fraimovich)
|
||||
229ca6c #1323: Add: collect runtime metrics for Celery tasks (Arik Fraimovich)
|
||||
931a1f3 #1315: Add: support for loading BigQuery schema (Arik Fraimovich)
|
||||
39b4f9a #1314: Add: support MongoDB SSL connections (Arik Fraimovich)
|
||||
ca1ca9b #1312: Add: additional configuration for Celery jobs (Arik Fraimovich)
|
||||
fc00e61 #1310: Add: support for date/time with seconds parameters (Arik Fraimovich)
|
||||
d72a198 #1307: Add: API to force refresh data source schema (Arik Fraimovich)
|
||||
beb89ec #1305: Add: UI to edit dashboard text box widget (Kazuhito Hokamura)
|
||||
808fdd4 #1298: Add: JIRA (JQL) query runner (Arik Fraimovich)
|
||||
ff9e844 #1280: Add: configuration flag to disable scheduled queries (Hirotaka Suzuki)
|
||||
ef4699a #1269: Add: Google Drive federated tables support in BigQuery query runner (Kurt Gooden)
|
||||
2eeb947 #1236: Add: query runner for Cassandra and ScyllaDB (syerushalmy)
|
||||
10b398e #1249: Add: override slack webhook parameters (mystelynx)
|
||||
2b5e340 #1252: Add: Schema loading support for Presto query runner (using information_schema) (Rohan Dhupelia)
|
||||
2aaf5dd #1250: Add: query snippets feature (Arik Fraimovich)
|
||||
8d8af73 #1226: Add: Sankey visualization (Arik Fraimovich)
|
||||
a02edda #1222: Add: additional results format for sunburst visualization (Arik Fraimovich)
|
||||
0e70188 #1213: Add: new sunburst sequence visualization (Arik Fraimovich)
|
||||
9a6d2d7 #1204: Add: show views in schema browser for Vertica data sources (Matthew Carter)
|
||||
600afa5 #1138: Add: ability to register user defined function (UDF) resources for BigQuery DataSource/Query (fabito)
|
||||
b410410 #1166: Add: "every 14 days" refresh option (Arik Fraimovich)
|
||||
|
||||
|
||||
### Changed
|
||||
|
||||
002f794 #1368: Change: added ability to disable auto update in admin views (Arik Fraimovich)
|
||||
aa5d14e #1366: Change: improve error message for exception in the Python query runner (deecay)
|
||||
880627c #1355: Change: pass the user object to the run_query method (Arik Fraimovich)
|
||||
23c605b #1342: SAML: specify entity id (zoetrope)
|
||||
015b1dc #1334: Change: allow specifying recipient address when sending email test message (Arik Fraimovich)
|
||||
39aaa2f #1292: Change: improvements to map visualization (Arik Fraimovich)
|
||||
b22191b #1332: Change: upgrade Python packages (Arik Fraimovich)
|
||||
23ba98b #1331: Celery: Upgrade Celery to more recent version. (Arik Fraimovich)
|
||||
3283116 #1330: Change: upgrade Requests to latest version. (Arik Fraimovich)
|
||||
39091e0 #1324: Change: add more logging and information for refresh schemas task (Arik Fraimovich)
|
||||
462faea #1316: Change: remove deprecated settings (Arik Fraimovich)
|
||||
73e1837 #1313: Change: more flexible column width calculation (Arik Fraimovich)
|
||||
e8eb840 #1279: Change: update bootstrap.sh to support Ubuntu 16.04 (IllusiveMilkman)
|
||||
8cf0252 #1262: Change: upgrade Plot.ly version and switch to smaller build (Arik Fraimovich)
|
||||
0b79fb8 #1306: Change: paginate queries page & add explicit urls. (Arik Fraimovich)
|
||||
41f99f5 #1299: Change: send Content-Type header (application/json) in query results responses (Tsuyoshi Tatsukawa)
|
||||
dfb1a20 #1297: Change: update Slack configuration titles. (Arik Fraimovich)
|
||||
8c1056c #1294: Change: don't annotate BigQuery queries (Arik Fraimovich)
|
||||
a3cf92e #1289: Change: use key_as_string when available (ElasticSearch query runner) (Arik Fraimovich)
|
||||
e155191 #1285: Change: do not display Oracle tablespace name in schema browser (Matthew Carter)
|
||||
6cbc39c #1282: Change: deduplicate Google Spreadsheet columns (Arik Fraimovich)
|
||||
4caf2e3 #1277: Set specific version of cryptography lib (Arik Fraimovich)
|
||||
d22f0d4 #1216: Change: bootstrap.sh - use non interactive dist-upgrade (Atsushi Sasaki)
|
||||
19530f4 #1245: Change: switch from CodeMirror to Ace editor (Arik Fraimovich)
|
||||
dfb92db #1234: Change: MongoDB query runner set DB name as mandatory (Arik Fraimovich)
|
||||
b750843 #1230: Change: annotate Presto queries with metadata (Noriaki Katayama)
|
||||
5b20fe2 #1217: Change: install libffi-dev for Cryptography (Ubuntu setup script) (Atsushi Sasaki)
|
||||
a9fac34 #1206: Change: update pymssql version to 2.1.3 (kitsuyui)
|
||||
5d43cbe #1198: Change: add support for Standard SQL in BigQuery query runner (mystelynx)
|
||||
84d0c22 #1193: Change: modify the argument order of moment.add function call (Kenya Yamaguchi)
|
||||
|
||||
|
||||
### Fixed
|
||||
|
||||
d6febb0 #1375: Fix: Download Dataset does not work when not logged in (Joshua Dechant)
|
||||
96553ad #1369: Fix: missing format call in Elasticsearch test method (Adam Griffiths)
|
||||
c57c765 #1365: Fix: compare retrieval times in UTC timezone (Allen Short)
|
||||
37dff5f #1360: Fix: connection test was broken for MySQL (ichihara)
|
||||
360028c #1359: Fix: schema loading query for Hive was wrong for non default schema (laughingman7743)
|
||||
7ee41d4 #1358: Fix: make sure all calls to run_query updated with new parameter (Arik Fraimovich)
|
||||
0d94479 #1329: Fix: Redis memory leak. (Arik Fraimovich)
|
||||
7145aa2 #1325: Fix: queries API was doing N+1 queries in most cases (Arik Fraimovich)
|
||||
cd2e927 #1311: Fix: BoxPlot visualization wasn't rendering on a dashboard (Arik Fraimovich)
|
||||
a562ce7 #1309: Fix: properly render checkboxes in dynamic forms (Arik Fraimovich)
|
||||
d48192c #1308: Fix: support for Unicode columns name in Google Spreadsheets (Arik Fraimovich)
|
||||
e42f93f #1283: Fix: schema browser was unstable after opening a table (Arik Fraimovich)
|
||||
170bd65 #1272: Fix: TreasureData get_schema method was returning array instead of string as column name (ariarijp)
|
||||
4710c41 #1265: Fix: refresh modal not working for unsaved query (Arik Fraimovich)
|
||||
bc3a5ab #1264: Fix: dashboard refresh not working (Arik Fraimovich)
|
||||
6202d09 #1240: Fix: when shared dashboard token not found, return 404 (Wesley Batista)
|
||||
93aac14 #1251: Fix: autocomplete went crazy when database has no autocomplete. (Arik Fraimovich)
|
||||
b8eca28 #1246: Fix: support large schemas in schema browser (Arik Fraimovich)
|
||||
b781003 #1223: Fix: Alert: when hipchat Alert.name is multibyte character, occur error. (toyama0919)
|
||||
0b928e6 #1227: Fix: Bower install fails in vagrant (Kazuhito Hokamura)
|
||||
a411af2 #1232: Fix: don't show warning when query string (parameters value) changes (Kazuhito Hokamura)
|
||||
3dbb5a6 #1221: Fix: sunburst didn't handle all cases of path lengths (Arik Fraimovich)
|
||||
a7cc1ee #1218: Fix: updated result not being saved when changing query text. (Arik Fraimovich)
|
||||
0617833 #1215: Fix: email alerts not working (Arik Fraimovich)
|
||||
78f65b1 #1187: Fix: read only users receive the permission error modal in query view (Arik Fraimovich)
|
||||
bba801f #1167: Fix the version of setuptools on bootstrap script for Ubuntu (Takuya Arita)
|
||||
ce81d69 #1160: Fix indentation in docker-compose-example.yml (Hirofumi Wakasugi)
|
||||
dd759fe #1155: Fix: make all configuration values of Oracle required (Arik Fraimovich)
|
||||
|
||||
### Docs
|
||||
a69ee0c #1225: Fix: RST formatting of the Vagrant documentation (Kazuhito Hokamura)
|
||||
03837c0 #1242: Docs: add warning re. quotes on column names and BigQuery (Ereli)
|
||||
9a98075 #1255: Docs: add documentation for InfluxDB (vishesh92)
|
||||
e0485de #1195: Docs: fix typo in maintenance page title (Antoine Augusti)
|
||||
7681d3e #1164: Docs: update permission documentation (Daniel Darabos)
|
||||
bcd3670 #1156: Docs: add SSL parameters to nginx configuration (Josh Cox)
|
||||
|
||||
## v0.11.1.b2095 - 2016-08-02
|
||||
|
||||
This is a hotfix release, which fixes an issue with email alerts in v0.11.0.
|
||||
|
||||
## v0.11.0.b2016 - 2016-07-03
|
||||
|
||||
The main features of this release are:
|
||||
|
||||
- Alert Destinations: ability to define multiple destinations for alert notifications (currently implemented: HipChat, Slack, Webhook and email).
|
||||
- The long-awaited UI for query parameters (see example in #1069).
|
||||
|
||||
Also, this release includes numerous smaller features, improvements, and bug fixes.
|
||||
|
||||
A big thank you goes to all who contributed code and documentation in this release: @AntoineAugusti, @James226, @adamlwgriffiths, @alexdebrie, @anthony-coble, @ariarijp, @dheerajrav, @edwardsharp, @machira, @nabilblk, @ninneko, @ordd, @tomerben, @toru-takahashi, @vishesh92, @vorakumar and @whummer.
|
||||
|
||||
### Added
|
||||
d5e5b24 #1136: Feature: add --org option to all relevant CLI commands. (@adamlwgriffiths)
|
||||
87e25f2 #1129: Feature: support for JSON query formatting (Mongo, ElasticSearch) (@arikfr)
|
||||
6bb2716 #1121: Show error when failing to communicate with server (@arikfr)
|
||||
f21276e #1119: Feature: add UI to delete alerts (@arikfr)
|
||||
8656540 #1069: Feature: UI for query parameters (@arikfr)
|
||||
790128c #1067: Feature: word cloud visualization (@anthony-coble)
|
||||
8b73a2b #1098: Feature: UI for alert destinations & new destination types (@alexdebrie)
|
||||
1fbeb5d #1092: Add Heroku support (@adamlwgriffiths)
|
||||
f64622d #1089: Add support for serialising UUID type within MSSQL #961 (@James226)
|
||||
857caab #1085: Feature: API to pause a data source (@arikfr)
|
||||
214aa3b #1060: Feature: support configuring user's groups with SAML (@vorakumar)
|
||||
e20a005 #1007: Issue#1006: Make bottom margin editable for Chart visualization (@vorakumar)
|
||||
6e0dd2b #1063: Add support for date/time Y axis (@tomerben)
|
||||
b5a4a6b #979: Feature: Add CLI to edit group permissions (@ninneko)
|
||||
6d495d2 #1014: Add server-side parameter handling for embeds (@whummer)
|
||||
5255804 #1091: Add caching for queries used in embeds (@whummer)
|
||||
|
||||
### Changed
|
||||
0314313 #1149: Presto QueryRunner supports tinyint and smallint (@toru-takahashi)
|
||||
8fa6fdb #1030: Make sure data sources list ordered by id (@arikfr)
|
||||
8df822e #1141: Make create data source button more prominent (@arikfr)
|
||||
96dd811 #1127: Mark basic_auth_password as secret (@adamlwgriffiths)
|
||||
ad65391 #1130: Improve Slack notification style (@AntoineAugusti)
|
||||
df637e3 #1116: Return meaningful error when there is no cached result. (@arikfr)
|
||||
65635ec #1102: Switch to HipChat V2 API (@arikfr)
|
||||
14fcf01 #1072: Remove counter from the tasks Done tab (as it always shows 50). #1047 (@arikfr)
|
||||
1a1160e #1062: DynamoDB: Better exception handling (@arikfr)
|
||||
ed45dcb #1044: Improve vagrant flow (@staritza)
|
||||
8b5dc8e #1036: Add optional block for more scripts in template (@arikfr)
|
||||
|
||||
### Fixed
|
||||
dbd48e1 #1143: Fix: use the email input type where needed (@ariarijp)
|
||||
7445972 #1142: Fix: dates in filters might be duplicated (@arikfr)
|
||||
5d0ed02 #1140: Fix: Hive should use the enabled variable (@arikfr)
|
||||
392627d #1139: Fix: Impala data source referencing wrong variable (@arikfr)
|
||||
c5bfbba #1133: Fix: query scrolling issues (@vishesh92)
|
||||
c01d266 #1128: Fix: visualization options not updating after changing type (@arikfr)
|
||||
6bc0e7a #1126: Fix #669: save fails when doing partial save of new query (@arikfr)
|
||||
3ce27b9 #1118: Fix: remove alerts for archived queries (@arikfr)
|
||||
4fabaae #1117: Fix #1052: filter not working for date/time values (@arikfr)
|
||||
c107c94 #1077: Fix: install needed dependencies to use Hive in Docker image (@nabilblk)
|
||||
abc790c #1115: Fix: allow non integers in alert reference value (@arikfr)
|
||||
4ec473c #1110: Fix #1109: mixed group permissions resulting in wrong permission (@arikfr)
|
||||
1ca5262 #1099: Fix RST syntax for links (@adamlwgriffiths)
|
||||
daa6c1c #1096: Fix typo in env variable VERSION_CHECK (@AntoineAugusti)
|
||||
cd06d27 #1095: Fix: use create_query permission for new query button. (@ordd)
|
||||
2bc0b27 #1061: Fix: area chart stacking doesn't work (@machira)
|
||||
8c21e91 #1108: Remove potnetially concurrency not safe code form enqueue_query (@arikfr)
|
||||
e831218 #1084: Fix #1049: duplicate alerts when data source belongs to multiple groups (@arikfr)
|
||||
6edb0ca #1080: Fix typo (@jeffwidman)
|
||||
64d7538 #1074: Fix: ElasticSearch wasn't using correct type names (@toyama0919)
|
||||
3f90dd9 #1064: Fix: old task trackers were not really removed (@arikfr)
|
||||
e10ecd2 #1058: Bring back filters if dashboard filters are enabled (@AntoineAugusti)
|
||||
701035f #1059: Fix: DynamoDB having issues when setting host (@arikfr)
|
||||
2924d4f #1040: Small fixes to visualizations view (@arikfr)
|
||||
fec0d5f #1037: Fix: multi filter wasn't working with __ syntax (@dheerajrav)
|
||||
b066ce4 #1033: Fix: only ask for notification permissions if wasn't denied (@arikfr)
|
||||
960c416 #1032: Fix: make sure we return dashboards only for current org only (@arikfr)
|
||||
b3844d3 #1029: Hive: close connection only if it exists (@arikfr)
|
||||
|
||||
### Docs
|
||||
6bb09d8 #1146: Docs: add a link to settings documentation. (@adamlwgriffiths)
|
||||
095e759 #1103: Docs: add section about monitoring (@AntoineAugusti)
|
||||
e942486 #1090: Contributing Guide (@arikfr)
|
||||
3037c4f #1066: Docs: command type-o fix. (@edwardsharp)
|
||||
2ee0065 #1038: Add an ISSUE_TEMPLATE.md to direct people at the forum (@arikfr)
|
||||
f7322a4 #1021: Vagrant docs: add purging the cache step (@ariarijp)
|
||||
|
||||
---
|
||||
|
||||
For older releases check the GitHub releases page:
|
||||
https://github.com/getredash/redash/releases
|
||||
77
CONTRIBUTING.md
Normal file
77
CONTRIBUTING.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# Contributing Guide
|
||||
|
||||
Thank you for taking the time to contribute! :tada::+1:
|
||||
|
||||
The following is a set of guidelines for contributing to Redash. These are guidelines, not rules, please use your best judgement and feel free to propose changes to this document in a pull request.
|
||||
|
||||
## Quick Links:
|
||||
|
||||
- [Feature Roadmap](https://trello.com/b/b2LUHU7A/re-dash-roadmap)
|
||||
- [Feature Requests](https://discuss.redash.io/c/feature-requests)
|
||||
- [Gitter Chat](https://gitter.im/getredash/redash) or [Slack](https://slack.redash.io)
|
||||
- [Documentation](https://redash.io/help/)
|
||||
- [Blog](http://blog.redash.io/)
|
||||
- [Twitter](https://twitter.com/getredash)
|
||||
|
||||
---
|
||||
:star: If you already here and love the project, please make sure to press the Star button. :star:
|
||||
|
||||
---
|
||||
|
||||
|
||||
## Table of Contents
|
||||
|
||||
[How can I contribute?](#how-can-i-contribute)
|
||||
|
||||
- [Reporting Bugs](#reporting-bugs)
|
||||
- [Suggesting Enhancements / Feature Requests](#suggesting-enhancements--feature-requests)
|
||||
- [Pull Requests](#pull-requests)
|
||||
- [Documentation](#documentation)
|
||||
- Design?
|
||||
|
||||
[Addtional Notes](#additional-notes)
|
||||
|
||||
- [Release Method](#release-method)
|
||||
- [Code of Conduct](#code-of-conduct)
|
||||
|
||||
## How can I contribute?
|
||||
|
||||
### Reporting Bugs
|
||||
|
||||
When creating a new bug report, please make sure to:
|
||||
|
||||
- Search for existing issues first. If you find a previous report of your issue, please update the existing issue with additional information instead of creating a new one.
|
||||
- If you are not sure if your issue is really a bug or just some configuration/setup problem, please start a discussion in [the support forum](https://discuss.redash.io/c/support) first. Unless you can provide clear steps to reproduce, it's probably better to start with a thread in the forum and later to open an issue.
|
||||
- If you still decide to open an issue, please review the template and guidelines and include as much details as possible.
|
||||
|
||||
### Suggesting Enhancements / Feature Requests
|
||||
|
||||
If you would like to suggest an enchancement or ask for a new feature:
|
||||
|
||||
- Please check [the roadmap](https://trello.com/b/b2LUHU7A/re-dash-roadmap) for existing Trello card for what you want to suggest/ask. If there is, feel free to upvote it to signal interest or add your comments.
|
||||
- If there is no existing card, open a thread in [the forum](https://discuss.redash.io/c/feature-requests) to start a discussion about what you want to suggest. Try to provide as much details and context as possible and include information about *the problem you want to solve* rather only *your proposed solution*.
|
||||
|
||||
### Pull Requests
|
||||
|
||||
- **Code contributions are welcomed**. For big changes or significant features, it's usually better to reach out first and discuss what you want to implement and how (we recommend reading: [Pull Request First](https://medium.com/practical-blend/pull-request-first-f6bb667a9b6#.ozlqxvj36)). This to make sure that what you want to implement is aligned with our goals for the project and that no one else is already working on it.
|
||||
- Include screenshots and animated GIFs in your pull request whenever possible.
|
||||
- Please add [documentation](#documentation) for new features or changes in functionality along with the code.
|
||||
- Please follow existing code style. We use PEP8 for Python and sensible style for Javascript.
|
||||
|
||||
### Documentation
|
||||
|
||||
The project's documentation can be found at [https://redash.io/help/](https://redash.io/help/). The [documentation sources](https://github.com/getredash/website/tree/master/user-guide) are hosted on GitHub. To contribute edits / new pages, you can use GitHub's interface. Click the "Edit on GitHub" link on the documentation page to quickly open the edit interface.
|
||||
|
||||
## Additional Notes
|
||||
|
||||
### Release Method
|
||||
|
||||
We publish a stable release every ~2 months, although the goal is to get to a stable release every month. You can see the change log on [GitHub releases page](http://github.com/getredash/redash/releases).
|
||||
|
||||
Every build of the master branch updates the latest *RC release*. These releases are usually stable, but might contain regressions and therefore recommended for "advanced users" only.
|
||||
|
||||
When we release a new stable release, we also update the *latest* Docker image tag, the EC2 AMIs and GCE images.
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
This project adheres to the Contributor Covenant [code of conduct](http://redash.io/community/code_of_conduct). By participating, you are expected to uphold this code. Please report unacceptable behavior to team@redash.io.
|
||||
@@ -6,7 +6,7 @@ RUN apt-get update && \
|
||||
# Postgres client
|
||||
libpq-dev \
|
||||
# Additional packages required for data sources:
|
||||
libssl-dev libmysqlclient-dev freetds-dev && \
|
||||
libssl-dev libmysqlclient-dev freetds-dev libsasl2-dev && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
@@ -14,7 +14,7 @@ RUN apt-get update && \
|
||||
RUN useradd --system --comment " " --create-home redash
|
||||
|
||||
# Pip requirements for all data source types
|
||||
RUN pip install -U setuptools && \
|
||||
RUN pip install -U setuptools==23.1.0 && \
|
||||
pip install supervisor==3.1.2
|
||||
|
||||
COPY . /opt/redash/current
|
||||
@@ -32,7 +32,7 @@ RUN pip install -r requirements_all_ds.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 && \
|
||||
rm -rf node_modules rd_ui/node_modules /home/redash/.npm /home/redash/.cache && \
|
||||
apt-get purge -y nodejs && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
10
Makefile
10
Makefile
@@ -6,17 +6,17 @@ BASE_VERSION=$(shell python ./manage.py version | cut -d + -f 1)
|
||||
FILENAME=$(CIRCLE_ARTIFACTS)/$(NAME).$(VERSION).tar.gz
|
||||
|
||||
deps:
|
||||
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
|
||||
if [ -d "./rd_ui/app" ]; then npm install; fi
|
||||
if [ -d "./rd_ui/app" ]; then npm run bower install; fi
|
||||
if [ -d "./rd_ui/app" ]; then npm run build; fi
|
||||
|
||||
pack:
|
||||
sed -ri "s/^__version__ = '([0-9.]*)'/__version__ = '$(FULL_VERSION)'/" redash/__init__.py
|
||||
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" *
|
||||
tar -zcv -f $(FILENAME) --exclude="optipng*" --exclude=".git*" --exclude="*.pyc" --exclude="*.pyo" --exclude="venv" --exclude="node_modules" --exclude="rd_ui/dist/bower_components" --exclude="rd_ui/app" *
|
||||
|
||||
upload:
|
||||
python bin/release_manager.py $(CIRCLE_SHA1) $(BASE_VERSION) $(FILENAME)
|
||||
|
||||
test:
|
||||
nosetests --with-coverage --cover-package=redash tests/
|
||||
#cd rd_ui && grunt test
|
||||
#grunt test
|
||||
|
||||
2
Procfile.heroku
Normal file
2
Procfile.heroku
Normal file
@@ -0,0 +1,2 @@
|
||||
web: ./manage.py runserver -d -r -p $PORT --host 0.0.0.0
|
||||
worker: celery worker --app=redash.worker -c${REDASH_HEROKU_CELERY_WORKER_COUNT:-2} --beat -Q queries,celery,scheduled_queries
|
||||
32
README.md
32
README.md
@@ -1,56 +1,50 @@
|
||||
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"/>
|
||||
<img title="Redash" 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/getredash/redash.png?circle-token=8a695aa5ec2cbfa89b48c275aea298318016f040'/>
|
||||
</p>
|
||||
|
||||
[](https://gitter.im/getredash/redash?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
|
||||
[](http://docs.redash.io)
|
||||
[](https://redash.io/help/)
|
||||
|
||||
**_re:dash_** is our take on freeing the data within our company in a way that will better fit our culture and usage patterns.
|
||||
**_Redash_** 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.
|
||||
Prior to **_Redash_**, 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,
|
||||
**_Redash_** 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 **_Redash_** 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:
|
||||
**_Redash_** consists of two parts:
|
||||
|
||||
1. **Query Editor**: think of [JS Fiddle](http://jsfiddle.net) for SQL queries. It's your way to share data in the organization in an open way, by sharing both the dataset and the query that generated it. This way everyone can peer review not only the resulting dataset but also the process that generated it. Also it's possible to fork it and generate new datasets and reach new insights.
|
||||
2. **Dashboards/Visualizations**: once you have a dataset, you can create different visualizations out of it, and then combine several visualizations into a single dashboard. Currently it supports charts, pivot table and cohorts.
|
||||
|
||||
**_re:dash_** is a work in progress and has its rough edges and way to go to fulfill its full potential. The Query Editor part is quite solid, but the visualizations need more work to enrich them and to make them more user friendly.
|
||||
|
||||
## Demo
|
||||
|
||||
<img src="https://cloud.githubusercontent.com/assets/71468/12611424/1faf4d6a-c4f5-11e5-89b5-31efc1155d2c.gif" width="60%"/>
|
||||
<img src="https://cloud.githubusercontent.com/assets/71468/17391289/8e83878e-5a1d-11e6-8938-af9054a33b19.gif" width="60%"/>
|
||||
|
||||
You can try out the demo instance: http://demo.redash.io/ (login with any Google account).
|
||||
|
||||
## Getting Started
|
||||
|
||||
* [Setting up re:dash instance](http://redash.io/deployment/setup.html) (includes links to ready made AWS/GCE images).
|
||||
* [Documentation](http://docs.redash.io).
|
||||
* [Setting up Redash instance](https://redash.io/help-onpremise/setup/setting-up-redash-instance.html) (includes links to ready made AWS/GCE images).
|
||||
* [Documentation](https://redash.io/help/).
|
||||
|
||||
|
||||
## Getting Help
|
||||
|
||||
* Issues: https://github.com/getredash/redash/issues
|
||||
* Mailing List: https://groups.google.com/forum/#!forum/redash-users
|
||||
* Discussion Forum: https://discuss.redash.io/
|
||||
* Slack: http://slack.redash.io/
|
||||
* Gitter (chat): https://gitter.im/getredash/redash
|
||||
|
||||
## Reporting Bugs and Contributing Code
|
||||
|
||||
* 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, edit in a [dev environment](http://docs.redash.io/en/latest/dev/vagrant.html), and make a pull request. We need all the help we can get!
|
||||
* Want to help us build **_Redash_**? Fork the project, edit in a [dev environment](https://redash.io/help-onpremise/setup/setting-up-development-environment-using-vagrant.html), and make a pull request. We need all the help we can get!
|
||||
|
||||
## License
|
||||
|
||||
See [LICENSE](https://github.com/getredash/redash/blob/master/LICENSE) file.
|
||||
BSD-2-Clause.
|
||||
|
||||
4
Vagrantfile
vendored
4
Vagrantfile
vendored
@@ -8,4 +8,8 @@ Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
|
||||
config.vm.box = "redash/dev"
|
||||
config.vm.synced_folder "./", "/opt/redash/current"
|
||||
config.vm.network "forwarded_port", guest: 5000, host: 9001
|
||||
config.vm.provision "shell" do |s|
|
||||
s.inline = "/opt/redash/current/setup/vagrant/provision.sh"
|
||||
s.privileged = false
|
||||
end
|
||||
end
|
||||
|
||||
18
bin/pre_compile
Normal file
18
bin/pre_compile
Normal file
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
# Heroku pre_compile script
|
||||
|
||||
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||
pushd $DIR/..
|
||||
|
||||
# heroku requires cffi to be in requirements.txt in order for libffi to be installed.
|
||||
# https://github.com/heroku/heroku-buildpack-python/blob/master/bin/steps/cryptography
|
||||
# to avoid making it a requirement for other build systems, we'll inject it now
|
||||
# into the requirements.txt file
|
||||
|
||||
# Remove Heroku unsupported Python packages:
|
||||
grep -v -E "^(pymssql|thrift|sasl|pyhive)" requirements_all_ds.txt >> requirements.txt
|
||||
|
||||
# make the heroku Procfile the active one
|
||||
cp Procfile.heroku Procfile
|
||||
|
||||
popd
|
||||
21
bin/vagrant_ctl.sh
Executable file
21
bin/vagrant_ctl.sh
Executable file
@@ -0,0 +1,21 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
help() {
|
||||
echo "Usage: "
|
||||
echo "`basename "$0"` {start, test}"
|
||||
}
|
||||
|
||||
case "$1" in
|
||||
start)
|
||||
vagrant up
|
||||
vagrant ssh -c "cd /opt/redash/current; bin/run honcho start -f Procfile.dev;"
|
||||
;;
|
||||
test)
|
||||
vagrant up
|
||||
vagrant ssh -c "cd /opt/redash/current; make test"
|
||||
;;
|
||||
*)
|
||||
help
|
||||
;;
|
||||
esac
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "rdUi",
|
||||
"version": "0.1.0",
|
||||
"name": "redash",
|
||||
"version": "0.11.0",
|
||||
"dependencies": {
|
||||
"angular": "1.2.18",
|
||||
"angular-resource": "1.2.18",
|
||||
@@ -12,7 +12,6 @@
|
||||
"es5-shim": "2.0.8",
|
||||
"angular-moment": "0.10.3",
|
||||
"moment": "~2.8.0",
|
||||
"codemirror": "4.8.0",
|
||||
"underscore": "1.5.1",
|
||||
"pivottable": "2.0.2",
|
||||
"cornelius": "https://github.com/restorando/cornelius.git",
|
||||
@@ -35,7 +34,10 @@
|
||||
"angular-ui-sortable": "~0.13.4",
|
||||
"angular-resizable": "^1.2.0",
|
||||
"material-design-iconic-font": "^2.2.0",
|
||||
"plotly.js": "^1.9.0"
|
||||
"plotly.js": "~1.16.0",
|
||||
"angular-ui-ace": "bower",
|
||||
"angular-vs-repeat": "^1.1.7",
|
||||
"leaflet.markercluster": "^0.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"angular-mocks": "1.2.18",
|
||||
@@ -3,7 +3,7 @@ machine:
|
||||
- docker
|
||||
node:
|
||||
version:
|
||||
0.12.4
|
||||
6.1.0
|
||||
python:
|
||||
version:
|
||||
2.7.3
|
||||
@@ -14,14 +14,14 @@ dependencies:
|
||||
- pip install pymongo==3.2.1
|
||||
- if [ "$CIRCLE_BRANCH" = "master" ]; then make deps; fi
|
||||
cache_directories:
|
||||
- rd_ui/node_modules/
|
||||
- node_modules/
|
||||
- rd_ui/app/bower_components/
|
||||
test:
|
||||
override:
|
||||
- nosetests --with-xunit --xunit-file=$CIRCLE_TEST_REPORTS/junit.xml --with-coverage --cover-package=redash tests/
|
||||
deployment:
|
||||
github_and_docker:
|
||||
branch: [master, /hotfix_.*/]
|
||||
branch: master
|
||||
commands:
|
||||
- make pack
|
||||
- make upload
|
||||
|
||||
@@ -16,7 +16,7 @@ redis:
|
||||
postgres:
|
||||
image: postgres:9.3
|
||||
volumes:
|
||||
- /opt/postgres-data:/var/lib/postgresql/data
|
||||
- /opt/postgres-data:/var/lib/postgresql/data
|
||||
redash-nginx:
|
||||
image: redash/nginx:latest
|
||||
ports:
|
||||
|
||||
192
docs/Makefile
192
docs/Makefile
@@ -1,192 +0,0 @@
|
||||
# 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."
|
||||
110
docs/conf.py
110
docs/conf.py
@@ -1,110 +0,0 @@
|
||||
# -*- 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'2013-2016, Arik Fraimovich'
|
||||
author = u'Arik Fraimovich'
|
||||
|
||||
# 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'),
|
||||
]
|
||||
@@ -1,261 +0,0 @@
|
||||
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/identity/protocols/OAuth2ServiceAccount#creatinganaccount>`__).
|
||||
|
||||
|
||||
- **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/identity/protocols/OAuth2ServiceAccount#creatinganaccount>`__).
|
||||
|
||||
- **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
|
||||
|
||||
|
||||
|
||||
Microsoft SQL Server
|
||||
-----
|
||||
|
||||
- **Options**:
|
||||
|
||||
- Database (mandatory)
|
||||
- User #TODO: DB users only? What about domain users?
|
||||
- Password
|
||||
- Server
|
||||
- Port
|
||||
|
||||
- **Notes**:
|
||||
|
||||
- Data type support is currently quite limited.
|
||||
- Complex and new types are converted to strings in ``Re:dash``
|
||||
- Coerce into simpler types if needed using ``CAST()``
|
||||
- Known conversion issues for:
|
||||
- DATE
|
||||
- TIME
|
||||
- DATETIMEOFFSET
|
||||
|
||||
- **Additional requirements**:
|
||||
|
||||
- ``freetds-dev`` C library
|
||||
- ``pymsssql`` python package, requires FreeTDS to be installed first
|
||||
11
docs/dev.rst
11
docs/dev.rst
@@ -1,11 +0,0 @@
|
||||
Developer Information
|
||||
=====================
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
:glob:
|
||||
|
||||
dev/vagrant
|
||||
dev/*
|
||||
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
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 completely 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.
|
||||
@@ -1,30 +0,0 @@
|
||||
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
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
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. Update database schema to the latest version:
|
||||
|
||||
::
|
||||
|
||||
bin/run ./manage.py database drop_tables
|
||||
bin/run ./manage.py database create_tables
|
||||
bin/run ./manage.py users create --admin --password admin "Admin" "admin"
|
||||
|
||||
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.
|
||||
@@ -1,57 +0,0 @@
|
||||
.. 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://cloud.githubusercontent.com/assets/71468/12611424/1faf4d6a-c4f5-11e5-89b5-31efc1155d2c.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
|
||||
* Slack: http://slack.redash.io/
|
||||
* Gitter (chat): https://gitter.im/getredash/redash
|
||||
|
||||
TOC
|
||||
###
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
|
||||
setup
|
||||
upgrade
|
||||
datasources
|
||||
usage
|
||||
dev
|
||||
misc
|
||||
@@ -1,10 +0,0 @@
|
||||
Miscellaneous
|
||||
=============
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
:glob:
|
||||
|
||||
misc/*
|
||||
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
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::
|
||||
|
||||
dropdb 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 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.
|
||||
@@ -1,50 +0,0 @@
|
||||
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.
|
||||
@@ -1,141 +0,0 @@
|
||||
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
|
||||
@@ -1,59 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
sphinx
|
||||
sphinx-autobuild
|
||||
sphinx_rtd_theme
|
||||
@@ -1,60 +0,0 @@
|
||||
Settings
|
||||
########
|
||||
|
||||
Much of the functionality of Re:dash can be changes with settings. Settings are read by `/redash/settings.py` from environment variables which (for most installs) can be set in `/opt/redash/current/.env`
|
||||
|
||||
The follow is a list of settings and what they control:
|
||||
|
||||
- **REDASH_NAME**: name of the site, used in page titles, *default "Re:dash"*
|
||||
- **REDASH_REDIS_URL**: *default "redis://localhost:6379/0"*
|
||||
- **REDASH_PROXIES_COUNT**: *default "1"*
|
||||
- **REDASH_STATSD_HOST**: *default "127.0.0.1"*
|
||||
- **REDASH_STATSD_PORT**: *default "8125"*
|
||||
- **REDASH_STATSD_PREFIX**: *default "redash"*
|
||||
- **REDASH_DATABASE_URL**: *default "postgresql://postgres"*
|
||||
- **REDASH_CELERY_BROKER**: *default REDIS_URL*
|
||||
- **REDASH_CELERY_BACKEND**: *default CELERY_BROKER*
|
||||
- **REDASH_QUERY_RESULTS_CLEANUP_ENABLED**: *default "true"*
|
||||
- **REDASH_QUERY_RESULTS_CLEANUP_COUNT**: *default "100"*
|
||||
- **REDASH_QUERY_RESULTS_CLEANUP_MAX_AGE**: *default "7"*
|
||||
- **REDASH_AUTH_TYPE**: *default "api_key"*
|
||||
- **REDASH_PASSWORD_LOGIN_ENABLED**: *default "true"*
|
||||
- **REDASH_ENFORCE_HTTPS**: *default "false"*
|
||||
- **REDASH_MULTI_ORG**: *default "false"*
|
||||
- **REDASH_GOOGLE_CLIENT_ID**: *default ""*
|
||||
- **REDASH_GOOGLE_CLIENT_SECRET**: *default ""*
|
||||
- **REDASH_SAML_METADATA_URL**: *default ""*
|
||||
- **REDASH_SAML_CALLBACK_SERVER_NAME**: *default ""*
|
||||
- **REDASH_STATIC_ASSETS_PATH**: *default "../rd_ui/app/"*
|
||||
- **REDASH_JOB_EXPIRY_TIME**: *default 3600 * 6*
|
||||
- **REDASH_COOKIE_SECRET**: *default "c292a0a3aa32397cdb050e233733900f"*
|
||||
- **REDASH_LOG_LEVEL**: *default "INFO"*
|
||||
- **REDASH_MAIL_SERVER**: *default "localhost"*
|
||||
- **REDASH_MAIL_PORT**: *default 25*
|
||||
- **REDASH_MAIL_USE_TLS**: *default "false"*
|
||||
- **REDASH_MAIL_USE_SSL**: *default "false"*
|
||||
- **REDASH_MAIL_USERNAME**: *default None*
|
||||
- **REDASH_MAIL_PASSWORD**: *default None*
|
||||
- **REDASH_MAIL_DEFAULT_SENDER**: *default None*
|
||||
- **REDASH_MAIL_MAX_EMAILS**: *default None*
|
||||
- **REDASH_MAIL_ASCII_ATTACHMENTS**: *default "false"*
|
||||
- **REDASH_HOST**: *default ""*
|
||||
- **REDASH_HIPCHAT_API_TOKEN**: *default None*
|
||||
- **REDASH_HIPCHAT_API_URL**: *default None*
|
||||
- **REDASH_HIPCHAT_ROOM_ID**: *default None*
|
||||
- **REDASH_WEBHOOK_ENDPOINT**: *default None*
|
||||
- **REDASH_WEBHOOK_USERNAME**: *default None*
|
||||
- **REDASH_CORS_ACCESS_CONTROL_ALLOW_ORIGIN**: *default ""*
|
||||
- **REDASH_CORS_ACCESS_CONTROL_ALLOW_CREDENTIALS**: *default "false"*
|
||||
- **REDASH_CORS_ACCESS_CONTROL_REQUEST_METHOD**: *default GET, POST, PUT""*
|
||||
- **REDASH_CORS_ACCESS_CONTROL_ALLOW_HEADERS**: *default "Content-Type"*
|
||||
- **REDASH_ENABLED_QUERY_RUNNERS**: *default ",".join(default_query_runners)*
|
||||
- **REDASH_ADDITIONAL_QUERY_RUNNERS**: *default ""*
|
||||
- **REDASH_SENTRY_DSN**: *default ""*
|
||||
- **REDASH_ALLOW_SCRIPTS_IN_USER_INPUT**: disable sanitization of text input, allowing full HTML, *default "true"*
|
||||
- **REDASH_DATE_FORMAT**: *default "DD/MM/YY"*
|
||||
- **REDASH_FEATURE_ALLOW_ALL_TO_EDIT**: *default "true"*
|
||||
- **REDASH_FEATURE_TABLES_PERMISSIONS**: *default "false"*
|
||||
- **REDASH_VERSION_CEHCK**: *default "true"*
|
||||
- **REDASH_BIGQUERY_HTTP_TIMEOUT**: *default "600"*
|
||||
- **REDASH_SCHEMA_RUN_TABLE_SIZE_CALCULATIONS**: *default "false"*
|
||||
194
docs/setup.rst
194
docs/setup.rst
@@ -1,194 +0,0 @@
|
||||
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, Google Compute
|
||||
Cloud and Docker. 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-a7ddfbcd <https://console.aws.amazon.com/ec2/home?region=us-east-1#LaunchInstanceWizard:ami=ami-a7ddfbcd>`__
|
||||
- us-west-1: `ami-269feb46 <https://console.aws.amazon.com/ec2/home?region=us-west-1#LaunchInstanceWizard:ami=ami-269feb46>`__
|
||||
- us-west-2: `ami-435fba23 <https://console.aws.amazon.com/ec2/home?region=us-west-2#LaunchInstanceWizard:ami=ami-435fba23>`__
|
||||
- eu-west-1: `ami-b4c277c7 <https://console.aws.amazon.com/ec2/home?region=eu-west-1#LaunchInstanceWizard:ami=ami-b4c277c7>`__
|
||||
- eu-central-1: `ami-07ced76b <https://console.aws.amazon.com/ec2/home?region=eu-central-1#LaunchInstanceWizard:ami=ami-07ced76b>`__
|
||||
- sa-east-1: `ami-6e2eaf02 <https://console.aws.amazon.com/ec2/home?region=sa-east-1#LaunchInstanceWizard:ami=ami-6e2eaf02>`__
|
||||
- ap-northeast-1: `ami-aa5a64c4 <https://console.aws.amazon.com/ec2/home?region=ap-northeast-1#LaunchInstanceWizard:ami=ami-aa5a64c4>`__
|
||||
- ap-southeast-1: `ami-1c45897f <https://console.aws.amazon.com/ec2/home?region=ap-southeast-1#LaunchInstanceWizard:ami=ami-1c45897f>`__
|
||||
- ap-southeast-2: `ami-42b79221 <https://console.aws.amazon.com/ec2/home?region=ap-southeast-2#LaunchInstanceWizard:ami=ami-42b79221>`__
|
||||
|
||||
(the above AMIs are of version: 0.9.1)
|
||||
|
||||
When launching the instance make sure to use a security group, that **only** allows incoming traffic on: port 22 (SSH), 80 (HTTP) and 443 (HTTPS). These AMIs are based on Ubuntu so you will need to use the user ``ubuntu`` when connecting to the instance via SSH.
|
||||
|
||||
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-091-b1377" --source-uri gs://redash-images/redash.0.9.1.b1377.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-091-b1377-bq" --source-uri gs://redash-images/redash.0.9.1.b1377-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-091-b1377-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>`__.
|
||||
|
||||
Docker Compose
|
||||
------
|
||||
|
||||
1. Make sure you have a Docker machine up and running.
|
||||
2. Make sure your current working directory is the root of this GitHub repository.
|
||||
3. Run ``docker-compose up postgres``.
|
||||
4. Run ``./setup/docker/create_database.sh``. This will access the postgres container and set up the database.
|
||||
5. Run ``docker compose up``
|
||||
6. Run ``docker-machine ls``, take note of the ip for the Docker machine you are using, and open the web browser.
|
||||
7. Visit that Docker machine IP at port 80, and you should see a Re:dash login screen.
|
||||
|
||||
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 . Re:dash should be available using the server IP or DNS name
|
||||
you assigned to it. You can point your browser to this address, and login
|
||||
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 org 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 adding them to the admin group (from ``/groups`` page).
|
||||
|
||||
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>`).
|
||||
@@ -1,37 +0,0 @@
|
||||
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 <http://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. Install any new requirements - ``sudo pip install -r requirements.txt``
|
||||
9. Restart web server and celery workers.
|
||||
@@ -1,10 +0,0 @@
|
||||
Usage
|
||||
=====
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
:glob:
|
||||
|
||||
usage/*
|
||||
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
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"
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
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 restart 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:
|
||||
--------------------
|
||||
|
||||
Uncompressed backup: ``sudo -u redash pg_dump > backup_filename.sql``
|
||||
|
||||
Compressed backup: ``sudo -u redash pg_dump redash | gzip > backup_filename.gz``
|
||||
|
||||
Version
|
||||
=======
|
||||
|
||||
See current version:
|
||||
|
||||
``bin/run ./manage.py version``
|
||||
@@ -1,74 +0,0 @@
|
||||
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
|
||||
@@ -1,35 +0,0 @@
|
||||
Permissions Model
|
||||
#################
|
||||
|
||||
In version 0.9.0 we introduced a new permissions model based on groups. Each user by default joins the ``Default`` group, but
|
||||
can be a member of any number of groups.
|
||||
|
||||
Group membership defines the actions you're allowed to take (although currently there is no UI to edit group action permissions),
|
||||
but also what data sources you have access to (for this we have UI).
|
||||
|
||||
How does it work?
|
||||
=================
|
||||
|
||||
* Each user belongs to one or more groups. By default each user joins the ``Default`` group. So the common
|
||||
data sources, should be associated with this group.
|
||||
* Each data source will be associated with one or more groups. Each connection to a group will define,
|
||||
whether this group has full access to this data source (view existing queries and run new ones) or view only access,
|
||||
which allows only viewing existing queries and results.
|
||||
* Any dashboard can contain visualizations from any data source (as long as the creating user has access to them). When
|
||||
a user who doesn't have access to some visualization (because he doesn't have access to the data source) opens a dashboard,
|
||||
he will see that there is a visualization there but won't see the details.
|
||||
|
||||
.. figure:: https://cloud.githubusercontent.com/assets/71468/12002946/dc5032ca-ab16-11e5-90e7-aae9234a596b.png
|
||||
|
||||
Dashboard widget with a visualization the user doesn't have access to.
|
||||
|
||||
In current implementation all the users see a list of all the dashboards. Once `pull request #957 <https://github.com/getredash/redash/pull/957>`__
|
||||
gets merged, we will filter out dashboards from the list that the user has no access to any of their widgets.
|
||||
|
||||
|
||||
What if I want to limit the user to only some tables?
|
||||
=====================================================
|
||||
|
||||
The idea is to leverage your database's security model, and hence create a user with access to the tables/columns you
|
||||
want to give access to. Create a data source that is using this user and then associate it with a group of users who need
|
||||
this level of access.
|
||||
@@ -1,44 +0,0 @@
|
||||
Special Features
|
||||
#################
|
||||
|
||||
Re:dash has a lot of very useful features and most of them can be found easily when using the UI. This page features the less well-known ones.
|
||||
|
||||
Queries
|
||||
========
|
||||
It is possible to have filters for query results and visualizations. Thanks to filters, you can restrain the result to a certain or multiple values. Filters are enabled by following a naming convention for columns.
|
||||
|
||||
If you want to focus only on a specific value, you will need to alias your column to ``<columnName>::filter``. Here is an example:
|
||||
|
||||
.. code:: sql
|
||||
|
||||
select action as "action::filter", count (0) as "actions count"
|
||||
from events
|
||||
group by action
|
||||
|
||||
You can see this query and the rendered UI `here <http://demo.redash.io/queries/143/source#table>`_.
|
||||
|
||||
If you are interested in multi filters (meaning that you can select multiple values), you will need to alias your column to ``<columnName>::multi-filter``. Here is an example:
|
||||
|
||||
.. code:: sql
|
||||
|
||||
select action as "action::multi-filter", count (0) as "actions count"
|
||||
from events
|
||||
group by action
|
||||
|
||||
You can see this query and the rendered UI `here <http://demo.redash.io/queries/144/source#table>`_.
|
||||
|
||||
Note that you can use ``__filter`` or ``__multiFilter`` if your database doesn't support ``::`` in column names (such as BigQuery).
|
||||
|
||||
Dashboards
|
||||
==========
|
||||
It is possible to group multiple dashboards in the dashboards menu. To do this, you need to follow a naming convention by using a column (``:``) to separate the dashboard group and the actual dashboard name. For example, if you name 2 dashboards ``Foo: Bar`` and ``Foo: Baz``, they will be grouped under the ``Foo`` namespace in the dropdown menu.
|
||||
|
||||
If you've got queries that have some filters and you want to apply filters at the dashboard level (that apply to all queries), you need to set a flag. Can you do it through the admin interface at ``/admin/dashboard`` or you can do it by manually setting the column ``dashboard_filters_enabled`` of the table ``dashboards`` to ``TRUE`` in the Re:dash database.
|
||||
|
||||
Exporting query results to CSV or JSON
|
||||
======================================
|
||||
Query results can be automatically exported to CSV or JSON by using your API key. Your API key can be found when viewing your profile, from the top right menu in the navigation bar.
|
||||
|
||||
The format of the URL is the following: ``https://<redash_domain>/api/queries/<query_id>/results.(csv|json)?api_key=<your_api_key>``. Here is a working example: `<http://demo.redash.io/api/queries/63/results.json?api_key=874fcd93ce4b6ef87a9aad41c712bcd5d17cdc8f>`_.
|
||||
|
||||
Using this URL you can easily import query results directly into Google Spreadsheets, using the ``importdata`` function. For example: ``=importdata("...")``.
|
||||
@@ -7,17 +7,28 @@ var lazypipe = require('lazypipe');
|
||||
var rimraf = require('rimraf');
|
||||
var wiredep = require('wiredep').stream;
|
||||
var runSequence = require('run-sequence');
|
||||
var map = require('lodash.map');
|
||||
|
||||
var yeoman = {
|
||||
app: 'app',
|
||||
dist: 'dist'
|
||||
app: 'rd_ui/app',
|
||||
dist: 'rd_ui/dist'
|
||||
};
|
||||
|
||||
function applyAppPath(p) {
|
||||
if (typeof p === 'string') {
|
||||
return yeoman.app + p;
|
||||
} else {
|
||||
return map(p, function (path) {
|
||||
return applyAppPath(path);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var paths = {
|
||||
scripts: [yeoman.app + '/scripts/**/*.js'],
|
||||
styles: [yeoman.app + '/styles/**/*.css'],
|
||||
views: {
|
||||
main: [yeoman.app + '/index.html', 'app/vendor_scripts.html', 'app/login.html', 'app/embed.html', 'app/public.html', 'app/app_layout.html', 'app/signed_out_layout.html'],
|
||||
main: applyAppPath(['/index.html', '/vendor_scripts.html', '/login.html', '/embed.html', '/public.html', '/app_layout.html', '/signed_out_layout.html']),
|
||||
files: [yeoman.app + '/views/**/*.html']
|
||||
}
|
||||
};
|
||||
@@ -94,7 +105,7 @@ gulp.task('html', function () {
|
||||
});
|
||||
|
||||
gulp.task('images', function () {
|
||||
return gulp.src(yeoman.app + '/images/**/*')
|
||||
return gulp.src(applyAppPath(['/images/**/*']))
|
||||
.pipe($.cache($.imagemin({
|
||||
optimizationLevel: 5,
|
||||
progressive: true,
|
||||
@@ -103,18 +114,28 @@ gulp.task('images', function () {
|
||||
.pipe(gulp.dest(yeoman.dist + '/images'));
|
||||
});
|
||||
|
||||
gulp.task('leaflet', function () {
|
||||
return gulp.src(applyAppPath(['/bower_components/leaflet/dist/images/**/*']))
|
||||
.pipe($.cache($.imagemin({
|
||||
optimizationLevel: 5,
|
||||
progressive: true,
|
||||
interlaced: true
|
||||
})))
|
||||
.pipe(gulp.dest(yeoman.dist + '/styles/images'));
|
||||
});
|
||||
|
||||
gulp.task('copy:extras', function () {
|
||||
return gulp.src([yeoman.app + '/*/.*', 'app/google_login.png', 'favicon.ico', 'robots.txt'], { dot: true })
|
||||
return gulp.src(applyAppPath(['/*/.*', '/google_login.png', '/favicon.ico', '/robots.txt']), { dot: true })
|
||||
.pipe(gulp.dest(yeoman.dist));
|
||||
});
|
||||
|
||||
gulp.task('copy:fonts', function () {
|
||||
return gulp.src([yeoman.app + '/fonts/**/*', 'app/bower_components/font-awesome/fonts/*', 'app/bower_components/material-design-iconic-font/dist/fonts/*'])
|
||||
return gulp.src(applyAppPath(['/fonts/**/*', '/bower_components/font-awesome/fonts/*', '/bower_components/material-design-iconic-font/dist/fonts/*']))
|
||||
.pipe(gulp.dest(yeoman.dist + '/fonts'));
|
||||
});
|
||||
|
||||
gulp.task('build', ['clean:dist'], function () {
|
||||
runSequence(['images', 'copy:extras', 'copy:fonts', 'client:build']);
|
||||
runSequence(['images', 'leaflet', 'copy:extras', 'copy:fonts', 'client:build']);
|
||||
});
|
||||
|
||||
gulp.task('default', ['build']);
|
||||
13
manage.py
13
manage.py
@@ -8,12 +8,13 @@ from flask_script import Manager
|
||||
|
||||
from redash import settings, models, __version__
|
||||
from redash.wsgi import app
|
||||
from redash.cli import users, database, data_sources, organization
|
||||
from redash.cli import users, groups, 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("groups", groups.manager)
|
||||
manager.add_command("ds", data_sources.manager)
|
||||
manager.add_command("org", organization.manager)
|
||||
|
||||
@@ -46,12 +47,16 @@ def check_settings():
|
||||
for name, item in settings.all_settings().iteritems():
|
||||
print "{} = {}".format(name, item)
|
||||
|
||||
@manager.command
|
||||
def send_test_mail():
|
||||
|
||||
@manager.option('email', default=None, help="Email address to send test message to (default: the address you defined in MAIL_DEFAULT_SENDER)")
|
||||
def send_test_mail(email=None):
|
||||
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 email is None:
|
||||
email = settings.MAIL_DEFAULT_SENDER
|
||||
|
||||
mail.send(Message(subject="Test Message from re:dash", recipients=[email], body="Test message."))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import os
|
||||
from redash.models import db, Organization, Group
|
||||
from redash import settings
|
||||
from playhouse.migrate import PostgresqlMigrator, migrate
|
||||
|
||||
# The following is deprecated and should be defined with the Organization object
|
||||
GOOGLE_APPS_DOMAIN = settings.set_from_string(os.environ.get("REDASH_GOOGLE_APPS_DOMAIN", ""))
|
||||
|
||||
if __name__ == '__main__':
|
||||
migrator = PostgresqlMigrator(db.database)
|
||||
|
||||
@@ -9,7 +13,7 @@ if __name__ == '__main__':
|
||||
Organization.create_table()
|
||||
|
||||
default_org = Organization.create(name="Default", slug='default', settings={
|
||||
Organization.SETTING_GOOGLE_APPS_DOMAINS: list(settings.GOOGLE_APPS_DOMAIN)
|
||||
Organization.SETTING_GOOGLE_APPS_DOMAINS: list(GOOGLE_APPS_DOMAIN)
|
||||
})
|
||||
|
||||
column = Group.org
|
||||
|
||||
89
migrations/0023_add_notification_destination.py
Normal file
89
migrations/0023_add_notification_destination.py
Normal file
@@ -0,0 +1,89 @@
|
||||
import os
|
||||
import peewee
|
||||
from redash.models import db, NotificationDestination, AlertSubscription, Alert, Organization, User
|
||||
from redash.destinations import get_configuration_schema_for_destination_type
|
||||
from redash.utils.configuration import ConfigurationContainer
|
||||
from playhouse.migrate import PostgresqlMigrator, migrate
|
||||
|
||||
HIPCHAT_API_TOKEN = os.environ.get('REDASH_HIPCHAT_API_TOKEN', None)
|
||||
HIPCHAT_API_URL = os.environ.get('REDASH_HIPCHAT_API_URL', None)
|
||||
HIPCHAT_ROOM_ID = os.environ.get('REDASH_HIPCHAT_ROOM_ID', None)
|
||||
|
||||
WEBHOOK_ENDPOINT = os.environ.get('REDASH_WEBHOOK_ENDPOINT', None)
|
||||
WEBHOOK_USERNAME = os.environ.get('REDASH_WEBHOOK_USERNAME', None)
|
||||
WEBHOOK_PASSWORD = os.environ.get('REDASH_WEBHOOK_PASSWORD', None)
|
||||
|
||||
if __name__ == '__main__':
|
||||
migrator = PostgresqlMigrator(db.database)
|
||||
with db.database.transaction():
|
||||
|
||||
if not NotificationDestination.table_exists():
|
||||
NotificationDestination.create_table()
|
||||
|
||||
# Update alert subscription fields
|
||||
migrate(
|
||||
migrator.add_column('alert_subscriptions', 'destination_id', AlertSubscription.destination)
|
||||
)
|
||||
|
||||
try:
|
||||
org = Organization.get_by_slug('default')
|
||||
user = User.select().where(User.org==org, peewee.SQL("%s = ANY(groups)", org.admin_group.id)).get()
|
||||
except Exception:
|
||||
print "!!! Warning: failed finding default organization or admin user, won't migrate Webhook/HipChat alert subscriptions."
|
||||
exit()
|
||||
|
||||
if WEBHOOK_ENDPOINT:
|
||||
# Have all existing alerts send to webhook if already configured
|
||||
schema = get_configuration_schema_for_destination_type('webhook')
|
||||
conf = {'url': WEBHOOK_ENDPOINT}
|
||||
if WEBHOOK_USERNAME:
|
||||
conf['username'] = WEBHOOK_USERNAME
|
||||
conf['password'] = WEBHOOK_PASSWORD
|
||||
options = ConfigurationContainer(conf, schema)
|
||||
|
||||
webhook = NotificationDestination.create(
|
||||
org=org,
|
||||
user=user,
|
||||
name="Webhook",
|
||||
type="webhook",
|
||||
options=options
|
||||
)
|
||||
|
||||
for alert in Alert.select():
|
||||
AlertSubscription.create(
|
||||
user=user,
|
||||
destination=webhook,
|
||||
alert=alert
|
||||
)
|
||||
|
||||
if HIPCHAT_API_TOKEN:
|
||||
# Have all existing alerts send to HipChat if already configured
|
||||
schema = get_configuration_schema_for_destination_type('hipchat')
|
||||
|
||||
conf = {}
|
||||
|
||||
if HIPCHAT_API_URL:
|
||||
conf['url'] = '{url}/room/{room_id}/notification?auth_token={token}'.format(
|
||||
url=HIPCHAT_API_URL, room_id=HIPCHAT_ROOM_ID, token=HIPCHAT_API_TOKEN)
|
||||
else:
|
||||
conf['url'] = 'https://hipchat.com/v2/room/{room_id}/notification?auth_token={token}'.format(
|
||||
room_id=HIPCHAT_ROOM_ID, token=HIPCHAT_API_TOKEN)
|
||||
|
||||
options = ConfigurationContainer(conf, schema)
|
||||
|
||||
hipchat = NotificationDestination.create(
|
||||
org=org,
|
||||
user=user,
|
||||
name="HipChat",
|
||||
type="hipchat",
|
||||
options=options
|
||||
)
|
||||
|
||||
for alert in Alert.select():
|
||||
AlertSubscription.create(
|
||||
user=user,
|
||||
destination=hipchat,
|
||||
alert=alert
|
||||
)
|
||||
|
||||
db.close_db(None)
|
||||
10
migrations/0024_add_options_to_query.py
Normal file
10
migrations/0024_add_options_to_query.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from redash.models import db, Query
|
||||
from playhouse.migrate import PostgresqlMigrator, migrate
|
||||
|
||||
if __name__ == '__main__':
|
||||
migrator = PostgresqlMigrator(db.database)
|
||||
|
||||
with db.database.transaction():
|
||||
migrate(
|
||||
migrator.add_column('queries', 'options', Query.options),
|
||||
)
|
||||
8
migrations/0025_add_query_snippets_table.py
Normal file
8
migrations/0025_add_query_snippets_table.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from redash.models import db, QuerySnippet
|
||||
|
||||
if __name__ == '__main__':
|
||||
with db.database.transaction():
|
||||
if not QuerySnippet.table_exists():
|
||||
QuerySnippet.create_table()
|
||||
|
||||
db.close_db(None)
|
||||
22
migrations/0026_add_access_control_tables.py
Normal file
22
migrations/0026_add_access_control_tables.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from redash.models import db, Change, AccessPermission, Query, Dashboard
|
||||
from playhouse.migrate import PostgresqlMigrator, migrate
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
if not Change.table_exists():
|
||||
Change.create_table()
|
||||
|
||||
if not AccessPermission.table_exists():
|
||||
AccessPermission.create_table()
|
||||
|
||||
migrator = PostgresqlMigrator(db.database)
|
||||
|
||||
try:
|
||||
migrate(
|
||||
migrator.add_column('queries', 'version', Query.version),
|
||||
migrator.add_column('dashboards', 'version', Dashboard.version)
|
||||
)
|
||||
except Exception as ex:
|
||||
print "Error while adding version column to queries/dashboards. Maybe it already exists?"
|
||||
print ex
|
||||
|
||||
4
migrations/0026_remove_query_trackers_redis_key.py
Normal file
4
migrations/0026_remove_query_trackers_redis_key.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from redash import redis_connection
|
||||
|
||||
if __name__ == '__main__':
|
||||
redis_connection.delete('query_task_trackers')
|
||||
@@ -26,7 +26,8 @@
|
||||
"gulp-print": "^2.0.1",
|
||||
"gulp-rev-all": "^0.8.22",
|
||||
"bower": "~1.7.1",
|
||||
"gulp-cli": "~1.2.0"
|
||||
"gulp-cli": "~1.2.0",
|
||||
"lodash.map": "^4.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -34,7 +35,8 @@
|
||||
"scripts": {
|
||||
"test": "echo 'No tests.'",
|
||||
"build": "gulp build",
|
||||
"bower": "bower"
|
||||
"bower": "bower",
|
||||
"heroku-postbuild": "npm install --dev && npm run bower install && npm run build && npm prune --production"
|
||||
},
|
||||
"dependencies": {
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"directory": "app/bower_components"
|
||||
}
|
||||
1
rd_ui/.gitattributes
vendored
1
rd_ui/.gitattributes
vendored
@@ -1 +0,0 @@
|
||||
* text=auto
|
||||
4
rd_ui/.gitignore
vendored
4
rd_ui/.gitignore
vendored
@@ -1,4 +0,0 @@
|
||||
node_modules
|
||||
.tmp
|
||||
.sass-cache
|
||||
app/bower_components
|
||||
@@ -1,6 +0,0 @@
|
||||
language: node_js
|
||||
node_js:
|
||||
- '0.10'
|
||||
before_script:
|
||||
- 'npm install -g bower grunt-cli'
|
||||
- 'bower install'
|
||||
@@ -12,16 +12,16 @@
|
||||
<!-- build:css /styles/main.css -->
|
||||
<link rel="stylesheet" href="/styles/superflat_redash.css">
|
||||
<link rel="stylesheet" href="/bower_components/material-design-iconic-font/dist/css/material-design-iconic-font.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="/bower_components/angular-resizable/src/angular-resizable.css">
|
||||
<link rel="stylesheet" href="/bower_components/leaflet.markercluster/dist/MarkerCluster.css">
|
||||
<link rel="stylesheet" href="/bower_components/leaflet.markercluster/dist/MarkerCluster.Default.css">
|
||||
<link rel="stylesheet" href="/styles/redash.css">
|
||||
<!-- endbuild -->
|
||||
|
||||
@@ -64,7 +64,9 @@
|
||||
|
||||
{% include 'vendor_scripts.html' %}
|
||||
|
||||
<!-- build:js({.tmp,app}) /scripts/scripts.js -->
|
||||
<!-- build:js({.tmp,rd_ui/app}) /scripts/scripts.js -->
|
||||
<script src="/scripts/vendor/cloud.js"></script>
|
||||
<script src="/scripts/vendor/d3.sankey.js"></script>
|
||||
<script src="/scripts/app.js"></script>
|
||||
<script src="/scripts/services/services.js"></script>
|
||||
<script src="/scripts/services/resources.js"></script>
|
||||
@@ -74,9 +76,11 @@
|
||||
<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/destinations.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/controllers/snippets.js"></script>
|
||||
<script src="/scripts/visualizations/base.js"></script>
|
||||
<script src="/scripts/visualizations/chart.js"></script>
|
||||
<script src="/scripts/visualizations/cohort.js"></script>
|
||||
@@ -86,10 +90,11 @@
|
||||
<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/visualizations/wordcloud.js"></script>
|
||||
<script src="/scripts/visualizations/sunburst_sequence.js"></script>
|
||||
<script src="/scripts/visualizations/sankey.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>
|
||||
|
||||
@@ -11,15 +11,15 @@
|
||||
|
||||
<!-- build:css /styles/embed.css -->
|
||||
<link rel="stylesheet" href="/styles/superflat_redash.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/material-design-iconic-font/dist/css/material-design-iconic-font.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="/bower_components/leaflet.markercluster/dist/MarkerCluster.css">
|
||||
<link rel="stylesheet" href="/bower_components/leaflet.markercluster/dist/MarkerCluster.Default.css">
|
||||
<link rel="stylesheet" href="/styles/redash.css">
|
||||
<!-- endbuild -->
|
||||
|
||||
@@ -37,7 +37,9 @@
|
||||
|
||||
{% include 'vendor_scripts.html' %}
|
||||
|
||||
<!-- build:js({.tmp,app}) /scripts/embed-scripts.js -->
|
||||
<!-- build:js({.tmp,rd_ui/app}) /scripts/embed-scripts.js -->
|
||||
<script src="/scripts/vendor/cloud.js"></script>
|
||||
<script src="/scripts/vendor/d3.sankey.js"></script>
|
||||
<script src="/scripts/embed.js"></script>
|
||||
<script src="/scripts/services/services.js"></script>
|
||||
<script src="/scripts/services/resources.js"></script>
|
||||
@@ -59,10 +61,11 @@
|
||||
<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/visualizations/wordcloud.js"></script>
|
||||
<script src="/scripts/visualizations/sunburst_sequence.js"></script>
|
||||
<script src="/scripts/visualizations/sankey.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>
|
||||
|
||||
BIN
rd_ui/app/favicon.ico
Normal file → Executable file
BIN
rd_ui/app/favicon.ico
Normal file → Executable file
Binary file not shown.
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -1,7 +1,5 @@
|
||||
{% extends 'app_layout.html' %}
|
||||
|
||||
{% block content %}
|
||||
<app-header></app-header>
|
||||
<edit-dashboard-form dashboard="newDashboard" id="new_dashboard_dialog"></edit-dashboard-form>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends "signed_out_layout.html" %}
|
||||
{% block title %}Login{% endblock %}
|
||||
{% block title %}Login | Redash{% endblock %}
|
||||
{% block content %}
|
||||
{% with messages = get_flashed_messages() %}
|
||||
{% if messages %}
|
||||
|
||||
@@ -14,10 +14,12 @@ angular.module('redash', [
|
||||
'ngResource',
|
||||
'ngRoute',
|
||||
'ui.select',
|
||||
'ui.ace',
|
||||
'naif.base64',
|
||||
'ui.bootstrap.showErrors',
|
||||
'angularResizable',
|
||||
'ngSanitize'
|
||||
'ngSanitize',
|
||||
'vs-repeat'
|
||||
]).config(['$routeProvider', '$locationProvider', '$compileProvider', 'growlProvider', 'uiSelectConfig', '$httpProvider',
|
||||
function ($routeProvider, $locationProvider, $compileProvider, growlProvider, uiSelectConfig, $httpProvider) {
|
||||
function getQuery(Query, $route) {
|
||||
@@ -71,6 +73,16 @@ angular.module('redash', [
|
||||
}]
|
||||
}
|
||||
});
|
||||
$routeProvider.when('/queries/my', {
|
||||
templateUrl: '/views/queries.html',
|
||||
controller: 'QueriesCtrl',
|
||||
reloadOnSearch: false
|
||||
});
|
||||
$routeProvider.when('/queries/drafts', {
|
||||
templateUrl: '/views/queries.html',
|
||||
controller: 'QueriesCtrl',
|
||||
reloadOnSearch: false
|
||||
});
|
||||
$routeProvider.when('/queries/search', {
|
||||
templateUrl: '/views/queries_search_results.html',
|
||||
controller: 'QuerySearchCtrl',
|
||||
@@ -115,6 +127,15 @@ angular.module('redash', [
|
||||
controller: 'DataSourcesCtrl'
|
||||
});
|
||||
|
||||
$routeProvider.when('/destinations/:destinationId', {
|
||||
templateUrl: '/views/destinations/edit.html',
|
||||
controller: 'DestinationCtrl'
|
||||
});
|
||||
$routeProvider.when('/destinations', {
|
||||
templateUrl: '/views/destinations/list.html',
|
||||
controller: 'DestinationsCtrl'
|
||||
});
|
||||
|
||||
$routeProvider.when('/users/new', {
|
||||
templateUrl: '/views/users/new.html',
|
||||
controller: 'NewUserCtrl'
|
||||
@@ -139,7 +160,15 @@ angular.module('redash', [
|
||||
$routeProvider.when('/groups', {
|
||||
templateUrl: '/views/groups/list.html',
|
||||
controller: 'GroupsCtrl'
|
||||
})
|
||||
});
|
||||
$routeProvider.when('/query_snippets/:snippetId', {
|
||||
templateUrl: '/views/query_snippets/show.html',
|
||||
controller: 'SnippetCtrl'
|
||||
});
|
||||
$routeProvider.when('/query_snippets', {
|
||||
templateUrl: '/views/query_snippets/list.html',
|
||||
controller: 'SnippetsCtrl'
|
||||
});
|
||||
$routeProvider.when('/', {
|
||||
templateUrl: '/views/index.html',
|
||||
controller: 'IndexCtrl'
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
var AdminTasksCtrl = function ($scope, $location, Events, $http, $timeout, $filter) {
|
||||
Events.record(currentUser, "view", "page", "admin/tasks");
|
||||
$scope.$parent.pageTitle = "Running Queries";
|
||||
$scope.autoUpdate = true;
|
||||
|
||||
$scope.gridConfig = {
|
||||
isPaginationEnabled: true,
|
||||
@@ -121,11 +122,13 @@
|
||||
$scope.setTab($location.hash() || 'in_progress');
|
||||
|
||||
var refresh = function () {
|
||||
$scope.refresh_time = moment().add('minutes', 1);
|
||||
$http.get('/api/admin/queries/tasks').success(function (data) {
|
||||
$scope.tasks = data;
|
||||
$scope.showingTasks = $scope.tasks[$scope.selectedTab];
|
||||
});
|
||||
if ($scope.autoUpdate) {
|
||||
$scope.refresh_time = moment().add(1, 'minutes');
|
||||
$http.get('/api/admin/queries/tasks').success(function (data) {
|
||||
$scope.tasks = data;
|
||||
$scope.showingTasks = $scope.tasks[$scope.selectedTab];
|
||||
});
|
||||
}
|
||||
|
||||
var timer = $timeout(refresh, 5 * 1000);
|
||||
|
||||
@@ -142,6 +145,7 @@
|
||||
var AdminOutdatedQueriesCtrl = function ($scope, Events, $http, $timeout, $filter) {
|
||||
Events.record(currentUser, "view", "page", "admin/outdated_queries");
|
||||
$scope.$parent.pageTitle = "Outdated Queries";
|
||||
$scope.autoUpdate = true;
|
||||
|
||||
$scope.gridConfig = {
|
||||
isPaginationEnabled: true,
|
||||
@@ -190,11 +194,13 @@
|
||||
];
|
||||
|
||||
var refresh = function () {
|
||||
$scope.refresh_time = moment().add('minutes', 1);
|
||||
$http.get('/api/admin/queries/outdated').success(function (data) {
|
||||
$scope.queries = data.queries;
|
||||
$scope.updatedAt = data.updated_at * 1000.0;
|
||||
});
|
||||
if ($scope.autoUpdate) {
|
||||
$scope.refresh_time = moment().add(1, 'minutes');
|
||||
$http.get('/api/admin/queries/outdated').success(function (data) {
|
||||
$scope.queries = data.queries;
|
||||
$scope.updatedAt = data.updated_at * 1000.0;
|
||||
});
|
||||
}
|
||||
|
||||
var timer = $timeout(refresh, 59 * 1000);
|
||||
|
||||
|
||||
@@ -46,7 +46,8 @@
|
||||
];
|
||||
};
|
||||
|
||||
var AlertCtrl = function($scope, $routeParams, $location, growl, Query, Events, Alert) {
|
||||
var AlertCtrl = function($scope, $routeParams, $location, growl, Query, Events, Alert, Destination) {
|
||||
$scope.selectedTab = 'users';
|
||||
$scope.$parent.pageTitle = "Alerts";
|
||||
|
||||
$scope.alertId = $routeParams.alertId;
|
||||
@@ -66,10 +67,12 @@
|
||||
|
||||
if ($scope.alertId === "new") {
|
||||
$scope.alert = new Alert({options: {}});
|
||||
$scope.canEdit = true;
|
||||
} else {
|
||||
$scope.alert = Alert.get({id: $scope.alertId}, function(alert) {
|
||||
$scope.onQuerySelected(new Query($scope.alert.query));
|
||||
});
|
||||
$scope.canEdit = currentUser.canEdit($scope.alert);
|
||||
}
|
||||
|
||||
$scope.ops = ['greater than', 'less than', 'equals'];
|
||||
@@ -108,69 +111,118 @@
|
||||
growl.addErrorMessage("Failed saving alert.");
|
||||
});
|
||||
};
|
||||
|
||||
$scope.delete = function() {
|
||||
$scope.alert.$delete(function() {
|
||||
$location.path('/alerts');
|
||||
growl.addSuccessMessage("Alert deleted.");
|
||||
}, function() {
|
||||
growl.addErrorMessage("Failed deleting alert.");
|
||||
});
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
angular.module('redash.directives').directive('alertSubscribers', ['AlertSubscription', function (AlertSubscription) {
|
||||
angular.module('redash.directives').directive('alertSubscriptions', ['$q', '$sce', 'AlertSubscription', 'Destination', 'growl', function ($q, $sce, AlertSubscription, Destination, growl) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
replace: true,
|
||||
templateUrl: '/views/alerts/subscribers.html',
|
||||
templateUrl: '/views/alerts/alert_subscriptions.html',
|
||||
scope: {
|
||||
'alertId': '='
|
||||
},
|
||||
controller: function ($scope) {
|
||||
$scope.subscribers = AlertSubscription.query({alertId: $scope.alertId});
|
||||
}
|
||||
}
|
||||
}]);
|
||||
$scope.newSubscription = {};
|
||||
$scope.subscribers = [];
|
||||
$scope.destinations = [];
|
||||
$scope.currentUser = currentUser;
|
||||
|
||||
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";
|
||||
var destinations = Destination.query().$promise;
|
||||
var subscribers = AlertSubscription.query({alertId: $scope.alertId}).$promise;
|
||||
|
||||
$q.all([destinations, subscribers]).then(function(responses) {
|
||||
var destinations = responses[0];
|
||||
var subscribers = responses[1];
|
||||
|
||||
var subscribedDestinations = _.compact(_.map(subscribers, function(s) { return s.destination && s.destination.id }));
|
||||
var subscribedUsers = _.compact(_.map(subscribers, function(s) { if (!s.destination) { return s.user.id } }));
|
||||
|
||||
$scope.destinations = _.filter(destinations, function(d) { return !_.contains(subscribedDestinations, d.id); });
|
||||
|
||||
if (!_.contains(subscribedUsers, currentUser.id)) {
|
||||
$scope.destinations.unshift({user: {name: currentUser.name}});
|
||||
}
|
||||
}
|
||||
|
||||
$scope.subscribers.$promise.then(function() {
|
||||
$scope.subscription = _.find($scope.subscribers, function(subscription) {
|
||||
return (subscription.user.email == currentUser.email);
|
||||
});
|
||||
|
||||
updateClass();
|
||||
$scope.newSubscription.destination = $scope.destinations[0];
|
||||
$scope.subscribers = subscribers;
|
||||
});
|
||||
|
||||
$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.");
|
||||
});
|
||||
$scope.destinationsDisplay = function(destination) {
|
||||
if (!destination) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
if (destination.destination) {
|
||||
destination = destination.destination;
|
||||
} else if (destination.user) {
|
||||
destination = {
|
||||
name: destination.user.name + ' (Email)',
|
||||
icon: 'fa-envelope',
|
||||
type: 'user'
|
||||
};
|
||||
}
|
||||
|
||||
return $sce.trustAsHtml('<i class="fa ' + destination.icon + '"></i> ' + destination.name);
|
||||
};
|
||||
|
||||
$scope.saveSubscriber = function() {
|
||||
var sub = new AlertSubscription({alert_id: $scope.alertId});
|
||||
if ($scope.newSubscription.destination.id) {
|
||||
sub.destination_id = $scope.newSubscription.destination.id;
|
||||
}
|
||||
|
||||
sub.$save(function () {
|
||||
growl.addSuccessMessage("Subscribed.");
|
||||
$scope.subscribers.push(sub);
|
||||
$scope.destinations = _.without($scope.destinations, $scope.newSubscription.destination);
|
||||
if ($scope.destinations.length > 0) {
|
||||
$scope.newSubscription.destination = $scope.destinations[0];
|
||||
} else {
|
||||
$scope.newSubscription.destination = undefined;
|
||||
}
|
||||
console.log("dests: ", $scope.destinations);
|
||||
}, function (response) {
|
||||
growl.addErrorMessage("Failed saving subscription.");
|
||||
});
|
||||
};
|
||||
|
||||
$scope.unsubscribe = function(subscriber) {
|
||||
var destination = subscriber.destination;
|
||||
var user = subscriber.user;
|
||||
|
||||
subscriber.$delete(function () {
|
||||
growl.addSuccessMessage("Unsubscribed");
|
||||
$scope.subscribers = _.without($scope.subscribers, subscriber);
|
||||
if (destination) {
|
||||
$scope.destinations.push(destination);
|
||||
} else if (user.id == currentUser.id) {
|
||||
$scope.destinations.push({user: {name: currentUser.name}});
|
||||
}
|
||||
|
||||
if ($scope.destinations.length == 1) {
|
||||
$scope.newSubscription.destination = $scope.destinations[0];
|
||||
}
|
||||
|
||||
}, function () {
|
||||
growl.addErrorMessage("Failed unsubscribing.");
|
||||
});
|
||||
};
|
||||
}
|
||||
}
|
||||
}]);
|
||||
|
||||
angular.module('redash.controllers')
|
||||
.controller('AlertsCtrl', ['$scope', 'Events', 'Alert', AlertsCtrl])
|
||||
.controller('AlertCtrl', ['$scope', '$routeParams', '$location', 'growl', 'Query', 'Events', 'Alert', AlertCtrl])
|
||||
.controller('AlertCtrl', ['$scope', '$routeParams', '$location', 'growl', 'Query', 'Events', 'Alert', 'Destination', AlertCtrl])
|
||||
|
||||
})();
|
||||
|
||||
@@ -63,91 +63,66 @@
|
||||
};
|
||||
|
||||
var QueriesCtrl = function ($scope, $http, $location, $filter, Query) {
|
||||
$scope.$parent.pageTitle = "All Queries";
|
||||
$scope.gridConfig = {
|
||||
isPaginationEnabled: true,
|
||||
itemsByPage: 50,
|
||||
maxSize: 8,
|
||||
isGlobalSearchActivated: true};
|
||||
var loader;
|
||||
|
||||
$scope.allQueries = [];
|
||||
$scope.queries = [];
|
||||
$scope.page = parseInt($location.search().page || 1);
|
||||
$scope.total = undefined;
|
||||
$scope.pageSize = 25;
|
||||
|
||||
var filterQueries = function () {
|
||||
$scope.queries = _.filter($scope.allQueries, function (query) {
|
||||
if (!$scope.selectedTab) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($scope.selectedTab.key == 'my') {
|
||||
return query.user.id == currentUser.id && query.name != 'New Query';
|
||||
} else if ($scope.selectedTab.key == 'drafts') {
|
||||
return query.user.id == currentUser.id && query.name == 'New Query';
|
||||
}
|
||||
|
||||
return query.name != 'New Query';
|
||||
});
|
||||
function loadQueries(resource, defaultOptions) {
|
||||
return function(options) {
|
||||
options = _.extend({}, defaultOptions, options);
|
||||
resource(options, function (queries) {
|
||||
$scope.totalQueriesCount = queries.count;
|
||||
$scope.queries = _.map(queries.results, function (query) {
|
||||
query.created_at = moment(query.created_at);
|
||||
query.retrieved_at = moment(query.retrieved_at);
|
||||
return query;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Query.query(function (queries) {
|
||||
$scope.allQueries = _.map(queries, function (query) {
|
||||
query.created_at = moment(query.created_at);
|
||||
query.retrieved_at = moment(query.retrieved_at);
|
||||
return query;
|
||||
});
|
||||
switch($location.path()) {
|
||||
case '/queries':
|
||||
$scope.$parent.pageTitle = "Queries";
|
||||
// page title
|
||||
loader = loadQueries(Query.query);
|
||||
break;
|
||||
case '/queries/drafts':
|
||||
$scope.$parent.pageTitle = "Drafts";
|
||||
loader = loadQueries(Query.myQueries, {drafts: true});
|
||||
break;
|
||||
case '/queries/my':
|
||||
$scope.$parent.pageTitle = "My Queries";
|
||||
loader = loadQueries(Query.myQueries);
|
||||
break;
|
||||
}
|
||||
|
||||
filterQueries();
|
||||
});
|
||||
var loadAllQueries = loadQueries(Query.query);
|
||||
var loadMyQueries = loadQueries(Query.myQueries);
|
||||
|
||||
$scope.gridColumns = [
|
||||
{
|
||||
"label": "Name",
|
||||
"map": "name",
|
||||
"cellTemplateUrl": "/views/queries_query_name_cell.html"
|
||||
},
|
||||
{
|
||||
'label': 'Created By',
|
||||
'map': 'user.name'
|
||||
},
|
||||
{
|
||||
'label': 'Created At',
|
||||
'map': 'created_at',
|
||||
'formatFunction': dateFormatter
|
||||
},
|
||||
{
|
||||
'label': 'Runtime',
|
||||
'map': 'run_time',
|
||||
'formatFunction': function (value) {
|
||||
return $filter('durationHumanize')(value);
|
||||
}
|
||||
},
|
||||
{
|
||||
'label': 'Last Executed At',
|
||||
'map': 'retrieved_at',
|
||||
'formatFunction': dateFormatter
|
||||
},
|
||||
{
|
||||
'label': 'Update Schedule',
|
||||
'map': 'schedule',
|
||||
'formatFunction': function (value) {
|
||||
return $filter('scheduleHumanize')(value);
|
||||
}
|
||||
}
|
||||
]
|
||||
function load() {
|
||||
var options = {page: $scope.page, page_size: $scope.pageSize};
|
||||
loader(options);
|
||||
}
|
||||
|
||||
$scope.selectPage = function(page) {
|
||||
$location.search('page', page);
|
||||
$scope.page = page;
|
||||
load();
|
||||
}
|
||||
|
||||
$scope.tabs = [
|
||||
{"name": "My Queries", "key": "my"},
|
||||
{"key": "all", "name": "All Queries"},
|
||||
{"key": "drafts", "name": "Drafts"}
|
||||
{"name": "My Queries", "path": "queries/my", loader: loadMyQueries},
|
||||
{"path": "queries", "name": "All Queries", isActive: function(path) {
|
||||
return path === '/queries';
|
||||
}, "loader": loadAllQueries},
|
||||
{"path": "queries/drafts", "name": "Drafts", loader: loadMyQueries},
|
||||
];
|
||||
|
||||
$scope.$watch('selectedTab', function (tab) {
|
||||
if (tab) {
|
||||
$scope.$parent.pageTitle = tab.name;
|
||||
}
|
||||
|
||||
filterQueries();
|
||||
});
|
||||
load();
|
||||
}
|
||||
|
||||
var MainCtrl = function ($scope, $location, Dashboard) {
|
||||
@@ -181,9 +156,76 @@
|
||||
$scope.recentDashboards = Dashboard.recent();
|
||||
};
|
||||
|
||||
// Controller for modal window share_permissions, works for both query and dashboards, needs apiAccess set in scope
|
||||
var ManagePermissionsCtrl = function ($scope, $http, $modalInstance, User) {
|
||||
$scope.grantees = [];
|
||||
$scope.newGrantees = {};
|
||||
|
||||
// List users that are granted permissions
|
||||
var loadGrantees = function() {
|
||||
$http.get($scope.apiAccess).success(function(result) {
|
||||
$scope.grantees = [];
|
||||
for(var access_type in result) {
|
||||
result[access_type].forEach(function(grantee) {
|
||||
var item = grantee;
|
||||
item['access_type'] = access_type;
|
||||
$scope.grantees.push(item);
|
||||
})
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
loadGrantees();
|
||||
|
||||
// Search for user
|
||||
$scope.findUser = function(search) {
|
||||
if (search == "") {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($scope.foundUsers === undefined) {
|
||||
User.query(function(users) {
|
||||
var existingIds = _.map($scope.grantees, function(m) { return m.id; });
|
||||
_.each(users, function(user) { user.alreadyGrantee = _.contains(existingIds, user.id); });
|
||||
$scope.foundUsers = users;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Add new user to grantees list
|
||||
$scope.addGrantee = function(user) {
|
||||
$scope.newGrantees.selected = undefined;
|
||||
var body = {'access_type': 'modify', 'user_id': user.id};
|
||||
$http.post($scope.apiAccess, body).success(function() {
|
||||
user.alreadyGrantee = true;
|
||||
loadGrantees();
|
||||
});
|
||||
};
|
||||
|
||||
// Remove user from grantees list
|
||||
$scope.removeGrantee = function(user) {
|
||||
var body = {'access_type': 'modify', 'user_id': user.id};
|
||||
$http({ url: $scope.apiAccess, method: 'DELETE',
|
||||
data: body, headers: {"Content-Type": "application/json"}
|
||||
}).success(function() {
|
||||
$scope.grantees = _.filter($scope.grantees, function(m) { return m != user });
|
||||
|
||||
if ($scope.foundUsers) {
|
||||
_.each($scope.foundUsers, function(u) { if (u.id == user.id) { u.alreadyGrantee = false }; });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
$scope.close = function() {
|
||||
$modalInstance.close();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
angular.module('redash.controllers', [])
|
||||
.controller('QueriesCtrl', ['$scope', '$http', '$location', '$filter', 'Query', QueriesCtrl])
|
||||
.controller('IndexCtrl', ['$scope', 'Events', 'Dashboard', 'Query', IndexCtrl])
|
||||
.controller('MainCtrl', ['$scope', '$location', 'Dashboard', MainCtrl])
|
||||
.controller('QuerySearchCtrl', ['$scope', '$location', '$filter', 'Events', 'Query', QuerySearchCtrl]);
|
||||
.controller('QuerySearchCtrl', ['$scope', '$location', '$filter', 'Events', 'Query', QuerySearchCtrl])
|
||||
.controller('ManagePermissionsCtrl', ['$scope', '$http', '$modalInstance', 'User', ManagePermissionsCtrl]);
|
||||
})();
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
$scope.refreshEnabled = false;
|
||||
$scope.isFullscreen = false;
|
||||
$scope.refreshRate = 60;
|
||||
$scope.showPermissionsControl = clientConfig.showPermissionsControl;
|
||||
|
||||
var renderDashboard = function (dashboard) {
|
||||
$scope.$parent.pageTitle = dashboard.name;
|
||||
@@ -91,10 +92,10 @@
|
||||
|
||||
_.each($scope.dashboard.widgets, function(row) {
|
||||
_.each(row, function(widget, i) {
|
||||
var newWidget = newWidgets[widget.id];
|
||||
var newWidget = newWidgets[widget.id][0];
|
||||
if (newWidget.visualization) {
|
||||
if (newWidget && newWidget[0].visualization.query.latest_query_data_id != widget.visualization.query.latest_query_data_id) {
|
||||
row[i] = new Widget(newWidget[0]);
|
||||
if (newWidget && newWidget.visualization.query.latest_query_data_id != widget.visualization.query.latest_query_data_id) {
|
||||
row[i] = new Widget(newWidget);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -114,7 +115,19 @@
|
||||
$scope.$parent.reloadDashboards();
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
$scope.showManagePermissionsModal = function() {
|
||||
// Create scope for share permissions dialog and pass api path to it
|
||||
var scope = $scope.$new();
|
||||
$scope.apiAccess = 'api/dashboards/' + $scope.dashboard.id + '/acl';
|
||||
|
||||
$modal.open({
|
||||
scope: scope,
|
||||
templateUrl: '/views/dialogs/manage_permissions.html',
|
||||
controller: 'ManagePermissionsCtrl'
|
||||
});
|
||||
};
|
||||
|
||||
$scope.toggleFullscreen = function() {
|
||||
$scope.isFullscreen = !$scope.isFullscreen;
|
||||
@@ -146,7 +159,7 @@
|
||||
}
|
||||
}));
|
||||
|
||||
$scope.refreshRate = _.max([120, refreshRate * 2]) * 1000;
|
||||
$scope.refreshRate = _.min([300, refreshRate]) * 1000;
|
||||
|
||||
autoRefresh();
|
||||
}
|
||||
@@ -188,7 +201,30 @@
|
||||
}
|
||||
};
|
||||
|
||||
var WidgetCtrl = function($scope, $location, Events, Query) {
|
||||
var WidgetCtrl = function($scope, $location, Events, Query, $modal) {
|
||||
$scope.editTextBox = function() {
|
||||
$modal.open({
|
||||
templateUrl: '/views/edit_text_box_form.html',
|
||||
scope: $scope,
|
||||
controller: ['$scope', '$modalInstance', 'growl', function($scope, $modalInstance, growl) {
|
||||
$scope.close = function() {
|
||||
$modalInstance.close();
|
||||
};
|
||||
|
||||
$scope.saveWidget = function() {
|
||||
$scope.saveInProgress = true;
|
||||
$scope.widget.$save().then(function(response) {
|
||||
$scope.close();
|
||||
}).catch(function() {
|
||||
growl.addErrorMessage("Widget can not be updated");
|
||||
}).finally(function() {
|
||||
$scope.saveInProgress = false;
|
||||
});
|
||||
};
|
||||
}],
|
||||
});
|
||||
}
|
||||
|
||||
$scope.deleteWidget = function() {
|
||||
if (!confirm('Are you sure you want to remove "' + $scope.widget.getName() + '" from the dashboard?')) {
|
||||
return;
|
||||
@@ -206,19 +242,26 @@
|
||||
$scope.dashboard.widgets = _.filter($scope.dashboard.widgets, function(row) { return row.length > 0 });
|
||||
|
||||
$scope.dashboard.layout = response.layout;
|
||||
$scope.dashboard.version = response.version;
|
||||
});
|
||||
};
|
||||
|
||||
Events.record(currentUser, "view", "widget", $scope.widget.id);
|
||||
|
||||
$scope.reload = function(force) {
|
||||
var maxAge = $location.search()['maxAge'];
|
||||
if (force) {
|
||||
maxAge = 0;
|
||||
}
|
||||
$scope.queryResult = $scope.query.getQueryResult(maxAge);
|
||||
};
|
||||
|
||||
if ($scope.widget.visualization) {
|
||||
Events.record(currentUser, "view", "query", $scope.widget.visualization.query.id);
|
||||
Events.record(currentUser, "view", "visualization", $scope.widget.visualization.id);
|
||||
|
||||
$scope.query = $scope.widget.getQuery();
|
||||
var parameters = Query.collectParamsFromQueryString($location, $scope.query);
|
||||
var maxAge = $location.search()['maxAge'];
|
||||
$scope.queryResult = $scope.query.getQueryResult(maxAge, parameters);
|
||||
$scope.reload(false);
|
||||
|
||||
$scope.type = 'visualization';
|
||||
} else if ($scope.widget.restricted) {
|
||||
@@ -231,6 +274,6 @@
|
||||
angular.module('redash.controllers')
|
||||
.controller('DashboardCtrl', ['$scope', 'Events', 'Widget', '$routeParams', '$location', '$http', '$timeout', '$q', '$modal', 'Dashboard', DashboardCtrl])
|
||||
.controller('PublicDashboardCtrl', ['$scope', 'Events', 'Widget', '$routeParams', '$location', '$http', '$timeout', '$q', 'Dashboard', PublicDashboardCtrl])
|
||||
.controller('WidgetCtrl', ['$scope', '$location', 'Events', 'Query', WidgetCtrl])
|
||||
.controller('WidgetCtrl', ['$scope', '$location', 'Events', 'Query', '$modal', WidgetCtrl])
|
||||
|
||||
})();
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
};
|
||||
|
||||
var DataSourceCtrl = function ($scope, $routeParams, $http, $location, Events, DataSource) {
|
||||
var DataSourceCtrl = function ($scope, $routeParams, $http, $location, growl, Events, DataSource) {
|
||||
Events.record(currentUser, "view", "page", "admin/data_source");
|
||||
$scope.$parent.pageTitle = "Data Sources";
|
||||
|
||||
@@ -24,9 +24,43 @@
|
||||
$location.path('/data_sources/' + id).replace();
|
||||
}
|
||||
});
|
||||
|
||||
function deleteDataSource() {
|
||||
Events.record(currentUser, "delete", "datasource", $scope.dataSource.id);
|
||||
|
||||
$scope.dataSource.$delete(function (resource) {
|
||||
growl.addSuccessMessage("Data source deleted successfully.");
|
||||
$location.path('/data_sources/');
|
||||
}.bind(this), function (httpResponse) {
|
||||
console.log("Failed to delete data source: ", httpResponse.status, httpResponse.statusText, httpResponse.data);
|
||||
growl.addErrorMessage("Failed to delete data source.");
|
||||
});
|
||||
}
|
||||
|
||||
function testConnection (callback) {
|
||||
Events.record(currentUser, "test", "datasource", $scope.dataSource.id);
|
||||
|
||||
DataSource.test({id: $scope.dataSource.id}, function (httpResponse) {
|
||||
if (httpResponse.ok) {
|
||||
growl.addSuccessMessage('<i class="fa fa-check-circle"></i> <strong>Success.</strong>', {enableHtml: true, ttl: 3000});
|
||||
} else {
|
||||
growl.addErrorMessage('<i class="fa fa-exclamation-triangle"></i> <strong>Connection Test Failed:</strong><br/>' + httpResponse.message, {enableHtml: true, ttl: -1});
|
||||
}
|
||||
callback();
|
||||
}, function (httpResponse) {
|
||||
console.log("Failed to test data source: ", httpResponse.status, httpResponse.statusText, httpResponse);
|
||||
growl.addErrorMessage('<i class="fa fa-exclamation-triangle"></i> <strong> Unknown error occurred while performing connection test. Please try again later.', {enableHtml: true, ttl: -1});
|
||||
callback();
|
||||
});
|
||||
}
|
||||
|
||||
$scope.actions = [
|
||||
{name: 'Delete', class: 'btn-danger', callback: deleteDataSource},
|
||||
{name: 'Test Connection', class: 'btn-default', callback: testConnection, disableWhenDirty: true}
|
||||
]
|
||||
};
|
||||
|
||||
angular.module('redash.controllers')
|
||||
.controller('DataSourcesCtrl', ['$scope', '$location', 'growl', 'Events', 'DataSource', DataSourcesCtrl])
|
||||
.controller('DataSourceCtrl', ['$scope', '$routeParams', '$http', '$location', 'Events', 'DataSource', DataSourceCtrl])
|
||||
.controller('DataSourceCtrl', ['$scope', '$routeParams', '$http', '$location', 'growl', 'Events', 'DataSource', DataSourceCtrl])
|
||||
})();
|
||||
|
||||
44
rd_ui/app/scripts/controllers/destinations.js
Normal file
44
rd_ui/app/scripts/controllers/destinations.js
Normal file
@@ -0,0 +1,44 @@
|
||||
(function () {
|
||||
var DestinationsCtrl = function ($scope, $location, growl, Events, Destination) {
|
||||
Events.record(currentUser, "view", "page", "admin/destinations");
|
||||
$scope.$parent.pageTitle = "Destinations";
|
||||
|
||||
$scope.destinations = Destination.query();
|
||||
|
||||
};
|
||||
|
||||
var DestinationCtrl = function ($scope, $routeParams, $http, $location, growl, Events, Destination) {
|
||||
Events.record(currentUser, "view", "page", "admin/destination");
|
||||
$scope.$parent.pageTitle = "Destinations";
|
||||
|
||||
$scope.destinationId = $routeParams.destinationId;
|
||||
|
||||
if ($scope.destinationId == "new") {
|
||||
$scope.destination = new Destination({options: {}});
|
||||
} else {
|
||||
$scope.destination = Destination.get({id: $routeParams.destinationId});
|
||||
}
|
||||
|
||||
$scope.$watch('destination.id', function(id) {
|
||||
if (id != $scope.destinationId && id !== undefined) {
|
||||
$location.path('/destinations/' + id).replace();
|
||||
}
|
||||
});
|
||||
|
||||
$scope.delete = function() {
|
||||
Events.record(currentUser, "delete", "destination", $scope.destination.id);
|
||||
|
||||
$scope.destination.$delete(function(resource) {
|
||||
growl.addSuccessMessage("Destination deleted successfully.");
|
||||
$location.path('/destinations/');
|
||||
}.bind(this), function(httpResponse) {
|
||||
console.log("Failed to delete destination: ", httpResponse.status, httpResponse.statusText, httpResponse.data);
|
||||
growl.addErrorMessage("Failed to delete destination.");
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
angular.module('redash.controllers')
|
||||
.controller('DestinationsCtrl', ['$scope', '$location', 'growl', 'Events', 'Destination', DestinationsCtrl])
|
||||
.controller('DestinationCtrl', ['$scope', '$routeParams', '$http', '$location', 'growl', 'Events', 'Destination', DestinationCtrl])
|
||||
})();
|
||||
@@ -1,7 +1,7 @@
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
function QuerySourceCtrl(Events, growl, $controller, $scope, $location, Query, Visualization, KeyboardShortcuts) {
|
||||
function QuerySourceCtrl(Events, growl, $controller, $scope, $location, $http, Query, Visualization, KeyboardShortcuts) {
|
||||
// extends QueryViewCtrl
|
||||
$controller('QueryViewCtrl', {$scope: $scope});
|
||||
// TODO:
|
||||
@@ -17,7 +17,7 @@
|
||||
saveQuery = $scope.saveQuery;
|
||||
|
||||
$scope.sourceMode = true;
|
||||
$scope.canEdit = currentUser.canEdit($scope.query);// TODO: bring this back? || clientConfig.allowAllToEditQueries;
|
||||
$scope.canEdit = currentUser.canEdit($scope.query) || $scope.query.can_edit;// TODO: bring this back? || clientConfig.allowAllToEditQueries;
|
||||
$scope.isDirty = false;
|
||||
$scope.base_url = $location.protocol()+"://"+$location.host()+":"+$location.port();
|
||||
|
||||
@@ -53,14 +53,25 @@
|
||||
$scope.saveQuery = function(options, data) {
|
||||
var savePromise = saveQuery(options, data);
|
||||
|
||||
if (!savePromise) {
|
||||
return;
|
||||
}
|
||||
|
||||
savePromise.then(function(savedQuery) {
|
||||
queryText = savedQuery.query;
|
||||
$scope.isDirty = $scope.query.query !== queryText;
|
||||
// update to latest version number
|
||||
$scope.query.version = savedQuery.version;
|
||||
|
||||
if (isNewQuery) {
|
||||
// redirect to new created query (keep hash)
|
||||
$location.path(savedQuery.getSourceLink());
|
||||
}
|
||||
}, function(error) {
|
||||
if(error.status == 409) {
|
||||
growl.addErrorMessage('It seems like the query has been modified by another user. ' +
|
||||
'Please copy/backup your changes and reload this page.', {ttl: -1});
|
||||
}
|
||||
});
|
||||
|
||||
return savePromise;
|
||||
@@ -110,7 +121,7 @@
|
||||
}
|
||||
|
||||
angular.module('redash.controllers').controller('QuerySourceCtrl', [
|
||||
'Events', 'growl', '$controller', '$scope', '$location', 'Query',
|
||||
'Visualization', 'KeyboardShortcuts', QuerySourceCtrl
|
||||
'Events', 'growl', '$controller', '$scope', '$location', '$http',
|
||||
'Query', 'Visualization', 'KeyboardShortcuts', QuerySourceCtrl
|
||||
]);
|
||||
})();
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
function QueryViewCtrl($scope, Events, $route, $location, notifications, growl, $modal, Query, DataSource) {
|
||||
function QueryViewCtrl($scope, Events, $route, $routeParams, $http, $location, notifications, growl, $modal, Query, DataSource, User) {
|
||||
var DEFAULT_TAB = 'table';
|
||||
|
||||
var getQueryResult = function(maxAge) {
|
||||
// Collect params, and getQueryResult with params; getQueryResult merges it into the query
|
||||
var parameters = Query.collectParamsFromQueryString($location, $scope.query);
|
||||
if (maxAge === undefined) {
|
||||
maxAge = $location.search()['maxAge'];
|
||||
}
|
||||
@@ -16,7 +14,7 @@
|
||||
}
|
||||
|
||||
$scope.showLog = false;
|
||||
$scope.queryResult = $scope.query.getQueryResult(maxAge, parameters);
|
||||
$scope.queryResult = $scope.query.getQueryResult(maxAge);
|
||||
};
|
||||
|
||||
var getDataSourceId = function() {
|
||||
@@ -68,6 +66,7 @@
|
||||
|
||||
$scope.dataSource = {};
|
||||
$scope.query = $route.current.locals.query;
|
||||
$scope.showPermissionsControl = clientConfig.showPermissionsControl;
|
||||
|
||||
var updateSchema = function() {
|
||||
$scope.hasSchema = false;
|
||||
@@ -82,6 +81,7 @@
|
||||
$scope.editorSize = "col-md-9";
|
||||
$scope.hasSchema = true;
|
||||
} else {
|
||||
$scope.schema = undefined;
|
||||
$scope.hasSchema = false;
|
||||
$scope.editorSize = "col-md-12";
|
||||
}
|
||||
@@ -125,9 +125,14 @@
|
||||
|
||||
$scope.saveQuery = function(options, data) {
|
||||
if (data) {
|
||||
// Don't save new query with partial data
|
||||
if ($scope.query.isNew()) {
|
||||
return;
|
||||
}
|
||||
data.id = $scope.query.id;
|
||||
data.version = $scope.query.version;
|
||||
} else {
|
||||
data = _.clone($scope.query);
|
||||
data = _.pick($scope.query, ["schedule", "query", "id", "description", "name", "data_source_id", "options", "latest_query_data_id", "version"]);
|
||||
}
|
||||
|
||||
options = _.extend({}, {
|
||||
@@ -135,13 +140,16 @@
|
||||
errorMessage: 'Query could not be saved'
|
||||
}, options);
|
||||
|
||||
delete data.latest_query_data;
|
||||
delete data.queryResult;
|
||||
|
||||
return Query.save(data, function() {
|
||||
return Query.save(data, function(updatedQuery) {
|
||||
growl.addSuccessMessage(options.successMessage);
|
||||
}, function(httpResponse) {
|
||||
growl.addErrorMessage(options.errorMessage);
|
||||
$scope.query.version = updatedQuery.version;
|
||||
}, function(error) {
|
||||
if(error.status == 409) {
|
||||
growl.addErrorMessage('It seems like the query has been modified by another user. ' +
|
||||
'Please copy/backup your changes and reload this page.', {ttl: -1});
|
||||
} else {
|
||||
growl.addErrorMessage(options.errorMessage);
|
||||
}
|
||||
}).$promise;
|
||||
}
|
||||
|
||||
@@ -324,6 +332,9 @@
|
||||
$modalInstance.close();
|
||||
}
|
||||
$scope.embedUrl = basePath + 'embed/query/' + query.id + '/visualization/' + visualization.id + '?api_key=' + query.api_key;
|
||||
if (window.snapshotUrlBuilder) {
|
||||
$scope.snapshotUrl = snapshotUrlBuilder(query, visualization);
|
||||
}
|
||||
}]
|
||||
})
|
||||
}
|
||||
@@ -336,9 +347,19 @@
|
||||
}
|
||||
$scope.selectedTab = hash || DEFAULT_TAB;
|
||||
});
|
||||
};
|
||||
|
||||
$scope.showManagePermissionsModal = function() {
|
||||
// Create scope for share permissions dialog and pass api path to it
|
||||
var scope = $scope.$new();
|
||||
$scope.apiAccess = 'api/queries/' + $routeParams.queryId + '/acl';
|
||||
|
||||
$modal.open({
|
||||
scope: scope,
|
||||
templateUrl: '/views/dialogs/manage_permissions.html',
|
||||
controller: 'ManagePermissionsCtrl'
|
||||
})
|
||||
};
|
||||
};
|
||||
angular.module('redash.controllers')
|
||||
.controller('QueryViewCtrl',
|
||||
['$scope', 'Events', '$route', '$location', 'notifications', 'growl', '$modal', 'Query', 'DataSource', QueryViewCtrl]);
|
||||
.controller('QueryViewCtrl', ['$scope', 'Events', '$route', '$routeParams', '$http', '$location', 'notifications', 'growl', '$modal', 'Query', 'DataSource', 'User', QueryViewCtrl]);
|
||||
})();
|
||||
|
||||
93
rd_ui/app/scripts/controllers/snippets.js
Normal file
93
rd_ui/app/scripts/controllers/snippets.js
Normal file
@@ -0,0 +1,93 @@
|
||||
(function() {
|
||||
var SnippetsCtrl = function ($scope, $location, growl, Events, QuerySnippet) {
|
||||
Events.record(currentUser, "view", "page", "query_snippets");
|
||||
$scope.$parent.pageTitle = "Query Snippets";
|
||||
|
||||
$scope.gridConfig = {
|
||||
isPaginationEnabled: true,
|
||||
itemsByPage: 20,
|
||||
maxSize: 8,
|
||||
};
|
||||
|
||||
$scope.gridColumns = [
|
||||
{
|
||||
"label": "Trigger",
|
||||
"cellTemplate": '<a href="query_snippets/{{dataRow.id}}">{{dataRow.trigger}}</a>'
|
||||
},
|
||||
{
|
||||
"label": "Description",
|
||||
"map": "description"
|
||||
},
|
||||
{
|
||||
"label": "Snippet",
|
||||
"map": "snippet"
|
||||
},
|
||||
{
|
||||
'label': 'Created By',
|
||||
'map': 'user.name'
|
||||
},
|
||||
{
|
||||
'label': 'Updated At',
|
||||
'cellTemplate': '<span am-time-ago="dataRow.created_at"></span>'
|
||||
}
|
||||
];
|
||||
|
||||
$scope.snippets = [];
|
||||
QuerySnippet.query(function(snippets) {
|
||||
$scope.snippets = snippets;
|
||||
});
|
||||
};
|
||||
|
||||
var SnippetCtrl = function ($scope, $routeParams, $http, $location, growl, Events, QuerySnippet) {
|
||||
$scope.$parent.pageTitle = "Query Snippets";
|
||||
$scope.snippetId = $routeParams.snippetId;
|
||||
Events.record(currentUser, "view", "query_snippet", $scope.snippetId);
|
||||
|
||||
$scope.editorOptions = {
|
||||
mode: 'snippets',
|
||||
advanced: {
|
||||
behavioursEnabled: true,
|
||||
enableSnippets: false,
|
||||
autoScrollEditorIntoView: true,
|
||||
},
|
||||
onLoad: function(editor) {
|
||||
editor.$blockScrolling = Infinity;
|
||||
editor.getSession().setUseWrapMode(true);
|
||||
editor.setShowPrintMargin(false);
|
||||
}
|
||||
};
|
||||
|
||||
$scope.saveChanges = function() {
|
||||
$scope.snippet.$save(function(snippet) {
|
||||
growl.addSuccessMessage("Saved.");
|
||||
if ($scope.snippetId === "new") {
|
||||
$location.path('/query_snippets/' + snippet.id).replace();
|
||||
}
|
||||
}, function() {
|
||||
growl.addErrorMessage("Failed saving snippet.");
|
||||
});
|
||||
}
|
||||
|
||||
$scope.delete = function() {
|
||||
$scope.snippet.$delete(function() {
|
||||
$location.path('/query_snippets');
|
||||
growl.addSuccessMessage("Query snippet deleted.");
|
||||
}, function() {
|
||||
growl.addErrorMessage("Failed deleting query snippet.");
|
||||
});
|
||||
}
|
||||
|
||||
if ($scope.snippetId == 'new') {
|
||||
$scope.snippet = new QuerySnippet({description: ""});
|
||||
$scope.canEdit = true;
|
||||
} else {
|
||||
$scope.snippet = QuerySnippet.get({id: $scope.snippetId}, function(snippet) {
|
||||
$scope.canEdit = currentUser.canEdit(snippet);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
angular.module('redash.controllers')
|
||||
.controller('SnippetsCtrl', ['$scope', '$location', 'growl', 'Events', 'QuerySnippet', SnippetsCtrl])
|
||||
.controller('SnippetCtrl', ['$scope', '$routeParams', '$http', '$location', 'growl', 'Events', 'QuerySnippet', SnippetCtrl])
|
||||
})();
|
||||
@@ -68,7 +68,7 @@
|
||||
},
|
||||
transclude: true,
|
||||
template:
|
||||
'<h2>'+
|
||||
'<h2 class="p-l-5">'+
|
||||
'<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>',
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
|
||||
var directives = angular.module('redash.directives');
|
||||
|
||||
directives.directive('editDashboardForm', ['Events', '$http', '$location', '$timeout', 'Dashboard',
|
||||
function(Events, $http, $location, $timeout, Dashboard) {
|
||||
directives.directive('editDashboardForm', ['Events', '$http', '$location', '$timeout', 'Dashboard', 'growl',
|
||||
function(Events, $http, $location, $timeout, Dashboard, growl) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
scope: {
|
||||
@@ -81,10 +81,19 @@
|
||||
$scope.dashboard.layout = layout;
|
||||
|
||||
layout = JSON.stringify(layout);
|
||||
Dashboard.save({slug: $scope.dashboard.id, name: $scope.dashboard.name, layout: layout}, function(dashboard) {
|
||||
Dashboard.save({slug: $scope.dashboard.id, name: $scope.dashboard.name,
|
||||
version: $scope.dashboard.version, layout: layout}, function(dashboard) {
|
||||
$scope.dashboard = dashboard;
|
||||
$scope.saveInProgress = false;
|
||||
$(element).modal('hide');
|
||||
}, function(error) {
|
||||
$scope.saveInProgress = false;
|
||||
if(error.status == 403) {
|
||||
growl.addErrorMessage("Unable to save dashboard: Permission denied.");
|
||||
} else if(error.status == 409) {
|
||||
growl.addErrorMessage('It seems like the dashboard has been modified by another user. ' +
|
||||
'Please copy/backup your changes and reload this page.', {ttl: -1});
|
||||
}
|
||||
});
|
||||
Events.record(currentUser, 'edit', 'dashboard', $scope.dashboard.id);
|
||||
} else {
|
||||
@@ -199,6 +208,7 @@
|
||||
widget.$save().then(function(response) {
|
||||
// update dashboard layout
|
||||
$scope.dashboard.layout = response['layout'];
|
||||
$scope.dashboard.version = response['version'];
|
||||
var newWidget = new Widget(response['widget']);
|
||||
if (response['new_row']) {
|
||||
$scope.dashboard.widgets.push([newWidget]);
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
(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', '$q', '$location', 'Events', function ($http, growl, $q, $location, Events) {
|
||||
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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
var typesPromise = $http.get('api/data_sources/types');
|
||||
|
||||
$q.all([typesPromise, $scope.dataSource.$promise]).then(function(responses) {
|
||||
var types = responses[0].data;
|
||||
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.");
|
||||
});
|
||||
}
|
||||
|
||||
$scope.deleteDataSource = function() {
|
||||
Events.record(currentUser, "delete", "datasource", $scope.dataSource.id);
|
||||
|
||||
$scope.dataSource.$delete(function(resource) {
|
||||
growl.addSuccessMessage("Data source deleted successfully.");
|
||||
$location.path('/data_sources/');
|
||||
}.bind(this), function(httpResponse) {
|
||||
console.log("Failed to delete data source: ", httpResponse.status, httpResponse.statusText, httpResponse.data);
|
||||
growl.addErrorMessage("Failed to delete data source.");
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}]);
|
||||
})();
|
||||
@@ -58,7 +58,7 @@
|
||||
}
|
||||
|
||||
$scope.$on('$locationChangeStart', function (event, next, current) {
|
||||
if (next.split("#")[0] == current.split("#")[0]) {
|
||||
if (next.split("?")[0] == current.split("?")[0] || next.split("#")[0] == current.split("#")[0]) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -92,13 +92,14 @@
|
||||
restrict: 'E',
|
||||
scope: {
|
||||
'tabId': '@',
|
||||
'name': '@'
|
||||
'name': '@',
|
||||
'basePath': '=?'
|
||||
},
|
||||
transclude: true,
|
||||
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.basePath = scope.basePath || $location.path().substring(1);
|
||||
scope.$watch(function () {
|
||||
return scope.$parent.selectedTab
|
||||
}, function (tab) {
|
||||
@@ -119,36 +120,6 @@
|
||||
}
|
||||
});
|
||||
|
||||
directives.directive('rdTabs', ['$location', function ($location) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
scope: {
|
||||
tabsCollection: '=',
|
||||
selectedTab: '='
|
||||
},
|
||||
template: '<ul class="tab-nav bg-white"><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;
|
||||
});
|
||||
}
|
||||
|
||||
$scope.$watch(function () {
|
||||
return $location.hash()
|
||||
}, function (hash) {
|
||||
if (hash) {
|
||||
$scope.selectTab($location.hash());
|
||||
} else {
|
||||
$scope.selectTab($scope.tabsCollection[0].key);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}]);
|
||||
|
||||
// From: http://jsfiddle.net/joshdmiller/NDFHg/
|
||||
directives.directive('editInPlace', function () {
|
||||
return {
|
||||
@@ -383,7 +354,109 @@
|
||||
'</div>' +
|
||||
'</div>'
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
directives.directive('dynamicForm', ['$http', 'growl', '$q', function ($http, growl, $q) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
replace: 'true',
|
||||
transclude: true,
|
||||
templateUrl: '/views/directives/dynamic_form.html',
|
||||
scope: {
|
||||
'target': '=',
|
||||
'type': '@type',
|
||||
'actions': '='
|
||||
},
|
||||
link: function ($scope) {
|
||||
var setType = function(types) {
|
||||
if ($scope.target.type === undefined) {
|
||||
$scope.target.type = types[0].type;
|
||||
return types[0];
|
||||
}
|
||||
|
||||
$scope.type = _.find(types, function (t) {
|
||||
return t.type == $scope.target.type;
|
||||
});
|
||||
};
|
||||
|
||||
$scope.inProgressActions = {};
|
||||
_.each($scope.actions, function(action) {
|
||||
var originalCallback = action.callback;
|
||||
var name = action.name;
|
||||
action.callback = function() {
|
||||
action.name = '<i class="zmdi zmdi-spinner zmdi-hc-spin"></i> ' + name;
|
||||
|
||||
$scope.inProgressActions[action.name] = true;
|
||||
function release() {
|
||||
$scope.inProgressActions[action.name] = false;
|
||||
action.name = name;
|
||||
}
|
||||
originalCallback(release);
|
||||
}
|
||||
});
|
||||
|
||||
$scope.files = {};
|
||||
|
||||
$scope.$watchCollection('files', function() {
|
||||
_.each($scope.files, function(v, k) {
|
||||
// THis is needed because angular-base64-upload sets the value to null at initialization, causing the field
|
||||
// to be marked as dirty even if it wasn't changed.
|
||||
if (!v && $scope.target.options[k]) {
|
||||
$scope.dataSourceForm.$setPristine();
|
||||
}
|
||||
if (v) {
|
||||
$scope.target.options[k] = v.base64;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
var typesPromise = $http.get('api/' + $scope.type + '/types');
|
||||
|
||||
$q.all([typesPromise, $scope.target.$promise]).then(function(responses) {
|
||||
var types = responses[0].data;
|
||||
setType(types);
|
||||
|
||||
$scope.types = 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('target.type', function(current, prev) {
|
||||
if (prev !== current) {
|
||||
if (prev !== undefined) {
|
||||
$scope.target.options = {};
|
||||
}
|
||||
setType($scope.types);
|
||||
}
|
||||
});
|
||||
|
||||
$scope.saveChanges = function() {
|
||||
$scope.target.$save(function() {
|
||||
growl.addSuccessMessage("Saved.");
|
||||
$scope.dataSourceForm.$setPristine()
|
||||
}, function() {
|
||||
growl.addErrorMessage("Failed saving.");
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}]);
|
||||
|
||||
directives.directive('pageHeader', function() {
|
||||
return {
|
||||
@@ -403,14 +476,148 @@
|
||||
restrict: 'E',
|
||||
transclude: true,
|
||||
templateUrl: '/views/directives/settings_screen.html',
|
||||
link: function(scope, elem, attrs) {
|
||||
controller: ['$scope', function(scope) {
|
||||
scope.usersPage = _.string.startsWith($location.path(), '/users');
|
||||
scope.groupsPage = _.string.startsWith($location.path(), '/groups');
|
||||
scope.dsPage = _.string.startsWith($location.path(), '/data_sources');
|
||||
scope.destinationsPage = _.string.startsWith($location.path(), '/destinations');
|
||||
scope.snippetsPage = _.string.startsWith($location.path(), '/query_snippets');
|
||||
|
||||
scope.showGroupsLink = currentUser.hasPermission('list_users');
|
||||
scope.showUsersLink = currentUser.hasPermission('list_users');
|
||||
scope.showDsLink = currentUser.hasPermission('admin');
|
||||
scope.showDestinationsLink = currentUser.hasPermission('admin');
|
||||
}]
|
||||
}
|
||||
}]);
|
||||
|
||||
directives.directive('tabNav', ['$location', function($location) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
transclude: true,
|
||||
scope: {
|
||||
tabs: '='
|
||||
},
|
||||
template: '<ul class="tab-nav bg-white">' +
|
||||
'<li ng-repeat="tab in tabs" ng-class="{\'active\': tab.active }"><a ng-href="{{tab.path}}">{{tab.name}}</a></li>' +
|
||||
'</ul>',
|
||||
link: function($scope) {
|
||||
_.each($scope.tabs, function(tab) {
|
||||
if (tab.isActive) {
|
||||
tab.active = tab.isActive($location.path());
|
||||
} else {
|
||||
tab.active = _.string.startsWith($location.path(), "/" + tab.path);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}]);
|
||||
|
||||
directives.directive('queriesList', [function () {
|
||||
return {
|
||||
restrict: 'E',
|
||||
replace: true,
|
||||
scope: {
|
||||
queries: '=',
|
||||
total: '=',
|
||||
selectPage: '=',
|
||||
page: '=',
|
||||
pageSize: '='
|
||||
},
|
||||
templateUrl: '/views/directives/queries_list.html',
|
||||
link: function ($scope) {
|
||||
function hasNext() {
|
||||
return !($scope.page * $scope.pageSize >= $scope.total);
|
||||
}
|
||||
|
||||
function hasPrevious() {
|
||||
return $scope.page !== 1;
|
||||
}
|
||||
|
||||
function updatePages() {
|
||||
if ($scope.total === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
var maxSize = 5;
|
||||
var pageCount = Math.ceil($scope.total/$scope.pageSize);
|
||||
var pages = [];
|
||||
|
||||
function makePage(title, page, disabled) {
|
||||
return {title: title, page: page, active: page == $scope.page, disabled: disabled};
|
||||
}
|
||||
|
||||
// Default page limits
|
||||
var startPage = 1, endPage = pageCount;
|
||||
|
||||
// recompute if maxSize
|
||||
if (maxSize && maxSize < pageCount) {
|
||||
startPage = Math.max($scope.page - Math.floor(maxSize / 2), 1);
|
||||
endPage = startPage + maxSize - 1;
|
||||
|
||||
// Adjust if limit is exceeded
|
||||
if (endPage > pageCount) {
|
||||
endPage = pageCount;
|
||||
startPage = endPage - maxSize + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Add page number links
|
||||
for (var number = startPage; number <= endPage; number++) {
|
||||
var page = makePage(number, number, false);
|
||||
pages.push(page);
|
||||
}
|
||||
|
||||
// Add previous & next links
|
||||
var previousPage = makePage('<', $scope.page - 1, !hasPrevious());
|
||||
pages.unshift(previousPage);
|
||||
|
||||
var nextPage = makePage('>', $scope.page + 1, !hasNext());
|
||||
pages.push(nextPage);
|
||||
|
||||
$scope.pages = pages;
|
||||
}
|
||||
|
||||
$scope.$watch('total', updatePages);
|
||||
$scope.$watch('page', updatePages);
|
||||
}
|
||||
}
|
||||
}]);
|
||||
|
||||
|
||||
directives.directive('parameters', ['$location', '$modal', function($location, $modal) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
transclude: true,
|
||||
scope: {
|
||||
'parameters': '=',
|
||||
'syncValues': '=?',
|
||||
'editable': '=?'
|
||||
},
|
||||
templateUrl: '/views/directives/parameters.html',
|
||||
link: function(scope, elem, attrs) {
|
||||
// is this the correct location for this logic?
|
||||
if (scope.syncValues !== false) {
|
||||
scope.$watch('parameters', function() {
|
||||
_.each(scope.parameters, function(param) {
|
||||
if (param.value !== null || param.value !== '') {
|
||||
$location.search('p_' + param.name, param.value);
|
||||
}
|
||||
})
|
||||
}, true);
|
||||
}
|
||||
|
||||
scope.showParameterSettings = function(param) {
|
||||
$modal.open({
|
||||
templateUrl: '/views/dialogs/parameter_settings.html',
|
||||
controller: ['$scope', '$modalInstance', function($scope, $modalInstance) {
|
||||
$scope.close = function() {
|
||||
$modalInstance.close();
|
||||
};
|
||||
$scope.parameter = param;
|
||||
}]
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}]);
|
||||
|
||||
@@ -45,59 +45,82 @@
|
||||
});
|
||||
};
|
||||
|
||||
var normalAreaStacking = function(seriesList) {
|
||||
fillXValues(seriesList);
|
||||
var storeOriginalHeightForEachSeries = function(seriesList) {
|
||||
_.each(seriesList, function(series) {
|
||||
if(!_.has(series,'visible')){
|
||||
series.visible = true;
|
||||
series.original_y = series.y.slice();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
var getEnabledSeries = function(seriesList){
|
||||
return _.filter(seriesList, function(series) {
|
||||
return series.visible === true;
|
||||
});
|
||||
};
|
||||
|
||||
var initializeTextAndHover = function(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 normalAreaStacking = function(seriesList) {
|
||||
fillXValues(seriesList);
|
||||
storeOriginalHeightForEachSeries(seriesList);
|
||||
initializeTextAndHover(seriesList);
|
||||
seriesList = getEnabledSeries(seriesList);
|
||||
|
||||
_.each(seriesList, function(series, seriesIndex, list){
|
||||
_.each(series.y, function(undefined, yIndex, undefined2){
|
||||
var cumulativeHeightOfPreviousSeries = seriesIndex > 0 ? list[seriesIndex-1].y[yIndex] : 0;
|
||||
var cumulativeHeightWithThisSeries = cumulativeHeightOfPreviousSeries + series.original_y[yIndex];
|
||||
series.y[yIndex] = cumulativeHeightWithThisSeries;
|
||||
series.text.push('Value: ' + series.original_y[yIndex] + '<br>Sum: ' + cumulativeHeightWithThisSeries);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
var lastVisibleY = function(seriesList, lastSeriesIndex, yIndex){
|
||||
for(; lastSeriesIndex >= 0; lastSeriesIndex--){
|
||||
if(seriesList[lastSeriesIndex].visible === true){
|
||||
return seriesList[lastSeriesIndex].y[yIndex];
|
||||
}
|
||||
}
|
||||
};
|
||||
return 0;
|
||||
}
|
||||
|
||||
var percentAreaStacking = function(seriesList) {
|
||||
if (seriesList.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
fillXValues(seriesList);
|
||||
_.each(seriesList, function(series) {
|
||||
series.text = [];
|
||||
series.hoverinfo = 'text+name';
|
||||
storeOriginalHeightForEachSeries(seriesList);
|
||||
initializeTextAndHover(seriesList);
|
||||
|
||||
_.each(seriesList[0].y, function(seriesY, yIndex, undefined){
|
||||
|
||||
var sumOfCorrespondingDataPoints = _.reduce(seriesList, function(total, series){
|
||||
return total + series.original_y[yIndex];
|
||||
}, 0);
|
||||
|
||||
_.each(seriesList, function(series, seriesIndex, list){
|
||||
var percentage = (series.original_y[yIndex] / sumOfCorrespondingDataPoints ) * 100;
|
||||
var previousVisiblePercentage = lastVisibleY(seriesList, seriesIndex-1, yIndex);
|
||||
series.y[yIndex] = percentage + previousVisiblePercentage;
|
||||
series.text.push('Value: ' + series.original_y[yIndex] + '<br>Relative: ' + percentage.toFixed(2) + '%');
|
||||
});
|
||||
});
|
||||
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';
|
||||
});
|
||||
initializeTextAndHover(seriesList);
|
||||
for (var i = 0; i < seriesList[0].y.length; i++) {
|
||||
var sum = 0;
|
||||
for(var j = 0; j < seriesList.length; j++) {
|
||||
@@ -118,9 +141,26 @@
|
||||
return value;
|
||||
}
|
||||
|
||||
function seriesMinValue(series) {
|
||||
return _.min(_.map(series, function(s) { return _.min(series.y) }));
|
||||
}
|
||||
|
||||
function seriesMaxValue(series) {
|
||||
return _.max(_.map(series, function(s) { return _.max(series.y) }));
|
||||
}
|
||||
|
||||
function leftAxisSeries(series) {
|
||||
return _.filter(series, function(s) { return s.yaxis !== 'y2' });
|
||||
}
|
||||
|
||||
function rightAxisSeries(series) {
|
||||
return _.filter(series, function(s) { return s.yaxis === 'y2' });
|
||||
}
|
||||
|
||||
angular.module('plotly', [])
|
||||
.constant('ColorPalette', ColorPalette)
|
||||
.directive('plotlyChart', function () {
|
||||
var bottomMargin = 50;
|
||||
return {
|
||||
restrict: 'E',
|
||||
template: '<div></div>',
|
||||
@@ -158,9 +198,18 @@
|
||||
return ColorPaletteArray[index % ColorPaletteArray.length];
|
||||
};
|
||||
|
||||
var calculateHeight = function() {
|
||||
var height = Math.max(scope.height, (scope.height - 50) + bottomMargin);
|
||||
return height;
|
||||
}
|
||||
|
||||
var recalculateOptions = function() {
|
||||
scope.data.length = 0;
|
||||
scope.layout.showlegend = _.has(scope.options, 'legend') ? scope.options.legend.enabled : true;
|
||||
if(_.has(scope.options, 'bottomMargin')) {
|
||||
bottomMargin = parseInt(scope.options.bottomMargin);
|
||||
scope.layout.margin.b = bottomMargin;
|
||||
}
|
||||
delete scope.layout.barmode;
|
||||
delete scope.layout.xaxis;
|
||||
delete scope.layout.yaxis;
|
||||
@@ -245,6 +294,7 @@
|
||||
return null;
|
||||
};
|
||||
|
||||
|
||||
scope.layout.xaxis = {title: getTitle(scope.options.xAxis),
|
||||
type: getScaleType(scope.options.xAxis.type)};
|
||||
if (angular.isDefined(scope.options.xAxis.labels)) {
|
||||
@@ -253,12 +303,26 @@
|
||||
if (angular.isArray(scope.options.yAxis)) {
|
||||
scope.layout.yaxis = {title: getTitle(scope.options.yAxis[0]),
|
||||
type: getScaleType(scope.options.yAxis[0].type)};
|
||||
|
||||
if (angular.isNumber(scope.options.yAxis[0].rangeMin) || angular.isNumber(scope.options.yAxis[0].rangeMax)) {
|
||||
var min = scope.options.yAxis[0].rangeMin || Math.min(0, seriesMinValue(leftAxisSeries(scope.data)));
|
||||
var max = scope.options.yAxis[0].rangeMax || seriesMaxValue(leftAxisSeries(scope.data));
|
||||
|
||||
scope.layout.yaxis.range = [min, max];
|
||||
}
|
||||
}
|
||||
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'};
|
||||
|
||||
if (angular.isNumber(scope.options.yAxis[1].rangeMin) || angular.isNumber(scope.options.yAxis[1].rangeMax)) {
|
||||
var min = scope.options.yAxis[1].rangeMin || Math.min(0, seriesMinValue(rightAxisSeries(scope.data)));
|
||||
var max = scope.options.yAxis[1].rangeMax || seriesMaxValue(rightAxisSeries(scope.data));
|
||||
|
||||
scope.layout.yaxis2.range = [min, max];
|
||||
}
|
||||
} else {
|
||||
delete scope.layout.yaxis2;
|
||||
}
|
||||
@@ -276,18 +340,39 @@
|
||||
percentBarStacking(scope.data);
|
||||
}
|
||||
}
|
||||
|
||||
scope.layout.margin.b = bottomMargin;
|
||||
scope.layout.height = calculateHeight();
|
||||
};
|
||||
|
||||
scope.$watch('series', recalculateOptions);
|
||||
scope.$watch('options', recalculateOptions, true);
|
||||
|
||||
scope.layout = {margin: {l: 50, r: 50, b: 50, t: 20, pad: 4}, height: scope.height, autosize: true, hovermode: 'closest'};
|
||||
scope.layout = {margin: {l: 50, r: 50, b: bottomMargin, t: 20, pad: 4}, height: calculateHeight(), autosize: true, hovermode: 'closest'};
|
||||
scope.plotlyOptions = {showLink: false, displaylogo: false};
|
||||
scope.data = [];
|
||||
|
||||
var element = element[0].children[0];
|
||||
Plotly.newPlot(element, scope.data, scope.layout, scope.plotlyOptions);
|
||||
|
||||
element.on('plotly_afterplot', function(d) {
|
||||
if(scope.options.globalSeriesType === 'area' && (scope.options.series.stacking === 'normal' || scope.options.series.stacking === 'percent')){
|
||||
$(element).find(".legendtoggle").each(function(i, rectDiv) {
|
||||
d3.select(rectDiv).on('click', function () {
|
||||
var maxIndex = scope.data.length - 1;
|
||||
var itemClicked = scope.data[maxIndex - i];
|
||||
|
||||
itemClicked.visible = (itemClicked.visible === true) ? 'legendonly' : true;
|
||||
if (scope.options.series.stacking === 'normal') {
|
||||
normalAreaStacking(scope.data);
|
||||
} else if (scope.options.series.stacking === 'percent') {
|
||||
percentAreaStacking(scope.data);
|
||||
}
|
||||
Plotly.redraw(element);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
scope.$watch('layout', function (layout, old) {
|
||||
if (angular.equals(layout, old)) {
|
||||
return;
|
||||
|
||||
@@ -10,29 +10,29 @@
|
||||
},
|
||||
template: '<a ng-href="{{link}}" class="query-link">{{query.name}}</a>',
|
||||
link: function(scope, element) {
|
||||
scope.link = 'queries/' + scope.query.id;
|
||||
var hash = null;
|
||||
if (scope.visualization) {
|
||||
if (scope.visualization.type === 'TABLE') {
|
||||
// link to hard-coded table tab instead of the (hidden) visualization tab
|
||||
scope.link += '#table';
|
||||
hash = 'table';
|
||||
} else {
|
||||
scope.link += '#' + scope.visualization.id;
|
||||
hash = scope.visualization.id;
|
||||
}
|
||||
}
|
||||
// element.find('a').attr('href', link);
|
||||
scope.link = scope.query.getUrl(false, hash);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function querySourceLink() {
|
||||
function querySourceLink($location) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
template: '<span ng-show="query.id && canViewSource">\
|
||||
<a ng-show="!sourceMode"\
|
||||
ng-href="queries/{{query.id}}/source#{{selectedTab}}" class="btn btn-default">Show Source\
|
||||
ng-href="{{query.getUrl(true, selectedTab)}}" class="btn btn-default">Show Source\
|
||||
</a>\
|
||||
<a ng-show="sourceMode"\
|
||||
ng-href="queries/{{query.id}}#{{selectedTab}}" class="btn btn-default">Hide Source\
|
||||
ng-href="{{query.getUrl(false, selectedTab)}}" class="btn btn-default">Hide Source\
|
||||
</a>\
|
||||
</span>'
|
||||
}
|
||||
@@ -52,7 +52,7 @@
|
||||
if (scope.queryResult.getId() == null) {
|
||||
element.attr('href', '');
|
||||
} else {
|
||||
element.attr('href', 'api/queries/' + scope.query.id + '/results/' + scope.queryResult.getId() + '.' + fileType);
|
||||
element.attr('href', 'api/queries/' + scope.query.id + '/results/' + scope.queryResult.getId() + '.' + fileType + (scope.embed ? '?api_key=' + scope.apiKey : ''));
|
||||
element.attr('download', scope.query.name.replace(" ", "_") + moment(scope.queryResult.getUpdatedAt()).format("_YYYY_MM_DD") + "." + fileType);
|
||||
}
|
||||
});
|
||||
@@ -60,7 +60,22 @@
|
||||
}
|
||||
}
|
||||
|
||||
function queryEditor() {
|
||||
// By default Ace will try to load snippet files for the different modes and fail. We don't need them, so we use these
|
||||
// placeholders until we define our own.
|
||||
function defineDummySnippets(mode) {
|
||||
ace.define("ace/snippets/" + mode, ["require", "exports", "module"], function(require, exports, module) {
|
||||
"use strict";
|
||||
|
||||
exports.snippetText = "";
|
||||
exports.scope = mode;
|
||||
});
|
||||
};
|
||||
|
||||
defineDummySnippets("python");
|
||||
defineDummySnippets("sql");
|
||||
defineDummySnippets("json");
|
||||
|
||||
function queryEditor(QuerySnippet) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
scope: {
|
||||
@@ -69,94 +84,106 @@
|
||||
'schema': '=',
|
||||
'syntax': '='
|
||||
},
|
||||
template: '<textarea></textarea>',
|
||||
template: '<div ui-ace="editorOptions" ng-model="query.query"></div>',
|
||||
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,
|
||||
extraKeys: {"Ctrl-Space": "autocomplete"}
|
||||
};
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
$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);
|
||||
$scope.editorOptions = {
|
||||
mode: 'json',
|
||||
require: ['ace/ext/language_tools'],
|
||||
advanced: {
|
||||
behavioursEnabled: true,
|
||||
enableSnippets: true,
|
||||
enableBasicAutocompletion: true,
|
||||
enableLiveAutocompletion: true,
|
||||
autoScrollEditorIntoView: true,
|
||||
},
|
||||
onLoad: function(editor) {
|
||||
QuerySnippet.query(function(snippets) {
|
||||
var snippetManager = ace.require("ace/snippets").snippetManager;
|
||||
var m = {
|
||||
snippetText: ''
|
||||
};
|
||||
m.snippets = snippetManager.parseSnippetFile(m.snippetText);
|
||||
_.each(snippets, function(snippet) {
|
||||
m.snippets.push(snippet.getSnippet());
|
||||
});
|
||||
|
||||
snippetManager.register(m.snippets || [], m.scope);
|
||||
});
|
||||
|
||||
additionalHints = _.unique(keywords);
|
||||
editor.$blockScrolling = Infinity;
|
||||
editor.getSession().setUseWrapMode(true);
|
||||
editor.setShowPrintMargin(false);
|
||||
|
||||
$scope.$watch('syntax', function(syntax) {
|
||||
var newMode = 'ace/mode/' + syntax;
|
||||
editor.getSession().setMode(newMode);
|
||||
});
|
||||
|
||||
$scope.$watch('schema', function(newSchema, oldSchema) {
|
||||
if (newSchema !== oldSchema) {
|
||||
var tokensCount = _.reduce(newSchema, function(totalLength, table) { return totalLength + table.columns.length }, 0);
|
||||
// If there are too many tokens we disable live autocomplete, as it makes typing slower.
|
||||
if (tokensCount > 5000) {
|
||||
editor.setOption('enableLiveAutocompletion', false);
|
||||
} else {
|
||||
editor.setOption('enableLiveAutocompletion', true);
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
$scope.$parent.$on("angular-resizable.resizing", function (event, args) {
|
||||
editor.resize();
|
||||
});
|
||||
|
||||
editor.focus();
|
||||
}
|
||||
};
|
||||
|
||||
codemirror.refresh();
|
||||
});
|
||||
var langTools = ace.require("ace/ext/language_tools");
|
||||
|
||||
$scope.$watch('syntax', function(syntax) {
|
||||
codemirror.setOption('mode', modes[syntax]);
|
||||
});
|
||||
var schemaCompleter = {
|
||||
getCompletions: function(state, session, pos, prefix, callback) {
|
||||
if (prefix.length === 0 || !$scope.schema) {
|
||||
callback(null, []);
|
||||
return;
|
||||
}
|
||||
|
||||
$scope.$watch('lock', function (locked) {
|
||||
var readOnly = locked ? 'nocursor' : false;
|
||||
codemirror.setOption('readOnly', readOnly);
|
||||
});
|
||||
if (!$scope.schema.keywords) {
|
||||
var keywords = {};
|
||||
|
||||
_.each($scope.schema, function (table) {
|
||||
keywords[table.name] = 'Table';
|
||||
|
||||
_.each(table.columns, function (c) {
|
||||
keywords[c] = 'Column';
|
||||
keywords[table.name + "." + c] = 'Column';
|
||||
});
|
||||
});
|
||||
|
||||
$scope.schema.keywords = _.map(keywords, function(v, k) {
|
||||
return {
|
||||
name: k,
|
||||
value: k,
|
||||
score: 0,
|
||||
meta: v
|
||||
};
|
||||
});
|
||||
}
|
||||
callback(null, $scope.schema.keywords);
|
||||
}
|
||||
};
|
||||
|
||||
langTools.addCompleter(schemaCompleter);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function queryFormatter($http) {
|
||||
function queryFormatter($http, growl) {
|
||||
return {
|
||||
restrict: 'E',
|
||||
// don't create new scope to avoid ui-codemirror bug
|
||||
@@ -165,23 +192,60 @@
|
||||
template: '<button type="button" class="btn btn-default btn-s"\
|
||||
ng-click="formatQuery()">\
|
||||
<span class="zmdi zmdi-format-indent-increase"></span>\
|
||||
Format SQL\
|
||||
Format Query\
|
||||
</button>',
|
||||
link: function($scope) {
|
||||
$scope.formatQuery = function formatQuery() {
|
||||
if ($scope.dataSource.syntax == 'json') {
|
||||
try {
|
||||
$scope.query.query = JSON.stringify(JSON.parse($scope.query.query), ' ', 4);
|
||||
} catch(err) {
|
||||
growl.addErrorMessage(err);
|
||||
}
|
||||
} else if ($scope.dataSource.syntax =='sql') {
|
||||
|
||||
$scope.queryFormatting = true;
|
||||
$http.post('api/queries/format', {
|
||||
'query': $scope.query.query
|
||||
'query': $scope.query.query
|
||||
}).success(function (response) {
|
||||
$scope.query.query = response;
|
||||
$scope.query.query = response;
|
||||
}).finally(function () {
|
||||
$scope.queryFormatting = false;
|
||||
});
|
||||
} else {
|
||||
growl.addInfoMessage("Query formatting is not supported for your data source syntax.");
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function schemaBrowser() {
|
||||
return {
|
||||
restrict: 'E',
|
||||
scope: {
|
||||
schema: '='
|
||||
},
|
||||
templateUrl: '/views/directives/schema_browser.html',
|
||||
link: function ($scope) {
|
||||
$scope.showTable = function(table) {
|
||||
table.collapsed = !table.collapsed;
|
||||
$scope.$broadcast('vsRepeatTrigger');
|
||||
}
|
||||
|
||||
$scope.getSize = function(table) {
|
||||
var size = 18;
|
||||
|
||||
if (!table.collapsed) {
|
||||
size += 18 * table.columns.length;
|
||||
}
|
||||
|
||||
return size;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function queryTimePicker() {
|
||||
return {
|
||||
restrict: 'E',
|
||||
@@ -263,11 +327,15 @@
|
||||
});
|
||||
$scope.refreshOptions.push({
|
||||
value: String(7 * 24 * 3600),
|
||||
name: 'Once a week'
|
||||
name: 'Every 7 days'
|
||||
});
|
||||
$scope.refreshOptions.push({
|
||||
value: String(14 * 24 * 3600),
|
||||
name: 'Every 14 days'
|
||||
});
|
||||
$scope.refreshOptions.push({
|
||||
value: String(30 * 24 * 3600),
|
||||
name: 'Every 30d'
|
||||
name: 'Every 30 days'
|
||||
});
|
||||
|
||||
$scope.$watch('refreshType', function() {
|
||||
@@ -285,10 +353,11 @@
|
||||
|
||||
angular.module('redash.directives')
|
||||
.directive('queryLink', queryLink)
|
||||
.directive('querySourceLink', querySourceLink)
|
||||
.directive('querySourceLink', ['$location', querySourceLink])
|
||||
.directive('queryResultLink', queryResultLink)
|
||||
.directive('queryEditor', queryEditor)
|
||||
.directive('queryEditor', ['QuerySnippet', queryEditor])
|
||||
.directive('queryRefreshSelect', queryRefreshSelect)
|
||||
.directive('queryTimePicker', queryTimePicker)
|
||||
.directive('queryFormatter', ['$http', queryFormatter]);
|
||||
.directive('schemaBrowser', schemaBrowser)
|
||||
.directive('queryFormatter', ['$http', 'growl', queryFormatter]);
|
||||
})();
|
||||
|
||||
@@ -49,6 +49,7 @@ angular.module('redash', [
|
||||
$scope.embed = true;
|
||||
$scope.visualization = visualization;
|
||||
$scope.query = visualization.query;
|
||||
$scope.apiKey = $location.search()['api_key'];
|
||||
query = new Query(visualization.query);
|
||||
$scope.queryResult = new QueryResult({query_result: query_result});
|
||||
}])
|
||||
|
||||
@@ -48,14 +48,15 @@ angular.module('redash.filters', []).
|
||||
|
||||
.filter('colWidth', function () {
|
||||
return function (widgetWidth) {
|
||||
if (widgetWidth == 0) {
|
||||
if (widgetWidth === 0) {
|
||||
return 0;
|
||||
}
|
||||
if (widgetWidth == 1) {
|
||||
} else if (widgetWidth === 1) {
|
||||
return 6;
|
||||
} else if (widgetWidth === 2) {
|
||||
return 12;
|
||||
}
|
||||
return 12;
|
||||
}
|
||||
return widgetWidth;
|
||||
};
|
||||
})
|
||||
|
||||
.filter('capitalize', function () {
|
||||
@@ -71,6 +72,9 @@ angular.module('redash.filters', []).
|
||||
|
||||
.filter('dateTime', function() {
|
||||
return function(value) {
|
||||
if (!value) {
|
||||
return '-';
|
||||
}
|
||||
return moment(value).format(clientConfig.dateTimeFormat);
|
||||
}
|
||||
})
|
||||
@@ -120,4 +124,10 @@ angular.module('redash.filters', []).
|
||||
filtered.push(items[i])
|
||||
return filtered;
|
||||
};
|
||||
})
|
||||
|
||||
.filter('notEmpty', function() {
|
||||
return function(collection) {
|
||||
return !_.isEmpty(collection);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
(function () {
|
||||
var Dashboard = function($resource, $http, Widget) {
|
||||
|
||||
var transformSingle = function(dashboard) {
|
||||
dashboard.widgets = _.map(dashboard.widgets, function (row) {
|
||||
return _.map(row, function (widget) {
|
||||
@@ -27,13 +28,13 @@
|
||||
isArray: true,
|
||||
url: "api/dashboards/recent",
|
||||
transformResponse: transform
|
||||
}});
|
||||
|
||||
}});
|
||||
resource.prototype.canEdit = function() {
|
||||
return currentUser.canEdit(this) || this.can_edit;
|
||||
};
|
||||
|
||||
resource.prototype.canEdit = function() {
|
||||
return currentUser.hasPermission('admin') || currentUser.canEdit(this);
|
||||
}
|
||||
return resource;
|
||||
return resource;
|
||||
}
|
||||
|
||||
angular.module('redash.services')
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (Notification.permission !== "granted") {
|
||||
if (Notification.permission === "default") {
|
||||
Notification.requestPermission(function (status) {
|
||||
if (Notification.permission !== status) {
|
||||
Notification.permission = status;
|
||||
|
||||
@@ -174,9 +174,14 @@
|
||||
};
|
||||
|
||||
return (memo && _.some(filter.current, function(v) {
|
||||
// We compare with either the value or the String representation of the value,
|
||||
// because Select2 casts true/false to "true"/"false".
|
||||
return v == row[filter.name] || String(row[filter.name]) == v
|
||||
var value = row[filter.name];
|
||||
if (moment.isMoment(value)) {
|
||||
return value.isSame(v);
|
||||
} else {
|
||||
// We compare with either the value or the String representation of the value,
|
||||
// because Select2 casts true/false to "true"/"false".
|
||||
return (v == value || String(value) == v);
|
||||
}
|
||||
}));
|
||||
}, true);
|
||||
});
|
||||
@@ -279,7 +284,7 @@
|
||||
var typeSplit;
|
||||
if (column.indexOf("::") != -1) {
|
||||
typeSplit = "::";
|
||||
} else if (column.indexOf("__" != -1)) {
|
||||
} else if (column.indexOf("__") != -1) {
|
||||
typeSplit = "__";
|
||||
} else {
|
||||
return column;
|
||||
@@ -353,7 +358,13 @@
|
||||
});
|
||||
|
||||
_.each(filters, function(filter) {
|
||||
filter.values = _.uniq(filter.values);
|
||||
filter.values = _.uniq(filter.values, function(v) {
|
||||
if (moment.isMoment(v)) {
|
||||
return v.unix();
|
||||
} else {
|
||||
return v;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
this.filters = filters;
|
||||
@@ -372,7 +383,10 @@
|
||||
refreshStatus(queryResult, query);
|
||||
}, 3000);
|
||||
}
|
||||
})
|
||||
}, function(error) {
|
||||
console.log("Connection error", error);
|
||||
queryResult.update({job: {error: 'failed communicating with server. Please check your Internet connection and try again.', status: 4}})
|
||||
});
|
||||
}
|
||||
|
||||
QueryResult.getById = function (id) {
|
||||
@@ -406,6 +420,11 @@
|
||||
}, function(error) {
|
||||
if (error.status === 403) {
|
||||
queryResult.update(error.data);
|
||||
} else if (error.status === 400 && 'job' in error.data) {
|
||||
queryResult.update(error.data);
|
||||
} else {
|
||||
console.log("Unknown error", error);
|
||||
queryResult.update({job: {error: 'unknown error occurred. Please try again later.', status: 4}})
|
||||
}
|
||||
});
|
||||
|
||||
@@ -415,7 +434,7 @@
|
||||
return QueryResult;
|
||||
};
|
||||
|
||||
var Query = function ($resource, QueryResult, DataSource) {
|
||||
var Query = function ($resource, $location, QueryResult) {
|
||||
var Query = $resource('api/queries/:id', {id: '@id'},
|
||||
{
|
||||
search: {
|
||||
@@ -427,32 +446,27 @@
|
||||
method: 'get',
|
||||
isArray: true,
|
||||
url: "api/queries/recent"
|
||||
}});
|
||||
},
|
||||
query: {
|
||||
isArray: false
|
||||
},
|
||||
myQueries: {
|
||||
method: 'get',
|
||||
isArray: false,
|
||||
url: "api/queries/my"
|
||||
}
|
||||
});
|
||||
|
||||
Query.newQuery = function () {
|
||||
return new Query({
|
||||
query: "",
|
||||
name: "New Query",
|
||||
schedule: null,
|
||||
user: currentUser
|
||||
user: currentUser,
|
||||
options: {}
|
||||
});
|
||||
};
|
||||
|
||||
Query.collectParamsFromQueryString = function($location, query) {
|
||||
var parameterNames = query.getParameters();
|
||||
var parameters = {};
|
||||
|
||||
var queryString = $location.search();
|
||||
_.each(parameterNames, function(param, i) {
|
||||
var qsName = "p_" + param;
|
||||
if (qsName in queryString) {
|
||||
parameters[param] = queryString[qsName];
|
||||
}
|
||||
});
|
||||
|
||||
return parameters;
|
||||
};
|
||||
|
||||
Query.prototype.getSourceLink = function () {
|
||||
return '/queries/' + this.id + '/source';
|
||||
};
|
||||
@@ -475,32 +489,31 @@
|
||||
};
|
||||
|
||||
Query.prototype.paramsRequired = function() {
|
||||
var queryParameters = this.getParameters();
|
||||
return !_.isEmpty(queryParameters);
|
||||
return this.getParameters().isRequired();
|
||||
};
|
||||
|
||||
Query.prototype.getQueryResult = function (maxAge, parameters) {
|
||||
Query.prototype.getQueryResult = function (maxAge) {
|
||||
if (!this.query) {
|
||||
return;
|
||||
}
|
||||
var queryText = this.query;
|
||||
|
||||
var queryParameters = this.getParameters();
|
||||
var paramsRequired = !_.isEmpty(queryParameters);
|
||||
var parameters = this.getParameters();
|
||||
var missingParams = parameters.getMissing();
|
||||
|
||||
var missingParams = parameters === undefined ? queryParameters : _.difference(queryParameters, _.keys(parameters));
|
||||
|
||||
if (paramsRequired && missingParams.length > 0) {
|
||||
if (missingParams.length > 0) {
|
||||
var paramsWord = "parameter";
|
||||
var valuesWord = "value";
|
||||
if (missingParams.length > 1) {
|
||||
paramsWord = "parameters";
|
||||
valuesWord = "values";
|
||||
}
|
||||
|
||||
return new QueryResult({job: {error: "Missing values for " + missingParams.join(', ') + " "+paramsWord+".", status: 4}});
|
||||
return new QueryResult({job: {error: "missing " + valuesWord + " for " + missingParams.join(', ') + " "+paramsWord+".", status: 4}});
|
||||
}
|
||||
|
||||
if (paramsRequired) {
|
||||
queryText = Mustache.render(queryText, parameters);
|
||||
if (parameters.isRequired()) {
|
||||
queryText = Mustache.render(queryText, parameters.getValues());
|
||||
|
||||
// Need to clear latest results, to make sure we don't use results for different params.
|
||||
this.latest_query_data = null;
|
||||
@@ -524,39 +537,148 @@
|
||||
return this.queryResult;
|
||||
};
|
||||
|
||||
Query.prototype.getUrl = function(source, hash) {
|
||||
var url = "queries/" + this.id;
|
||||
|
||||
if (source) {
|
||||
url += '/source';
|
||||
}
|
||||
|
||||
var params = "";
|
||||
if (this.getParameters().isRequired()) {
|
||||
_.each(this.getParameters().getValues(), function(value, name) {
|
||||
if (value === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (params !== "") {
|
||||
params += "&";
|
||||
}
|
||||
|
||||
params += 'p_' + encodeURIComponent(name) + "=" + encodeURIComponent(value);
|
||||
});
|
||||
}
|
||||
|
||||
if (params !== "") {
|
||||
url += "?" + params;
|
||||
}
|
||||
|
||||
if (hash) {
|
||||
url += "#" + hash;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
Query.prototype.getQueryResultPromise = function() {
|
||||
return this.getQueryResult().toPromise();
|
||||
};
|
||||
|
||||
Query.prototype.getParameters = function() {
|
||||
var parts = Mustache.parse(this.query);
|
||||
var parameters = [];
|
||||
var collectParams = function(parts) {
|
||||
parameters = [];
|
||||
_.each(parts, function(part) {
|
||||
if (part[0] == 'name' || part[0] == '&') {
|
||||
parameters.push(part[1]);
|
||||
} else if (part[0] == '#') {
|
||||
parameters = _.union(parameters, collectParams(part[4]));
|
||||
|
||||
var Parameters = function(query) {
|
||||
this.query = query;
|
||||
|
||||
this.parseQuery = function() {
|
||||
var parts = Mustache.parse(this.query.query);
|
||||
var parameters = [];
|
||||
var collectParams = function(parts) {
|
||||
parameters = [];
|
||||
_.each(parts, function(part) {
|
||||
if (part[0] == 'name' || part[0] == '&') {
|
||||
parameters.push(part[1]);
|
||||
} else if (part[0] == '#') {
|
||||
parameters = _.union(parameters, collectParams(part[4]));
|
||||
}
|
||||
});
|
||||
return parameters;
|
||||
};
|
||||
|
||||
parameters = _.uniq(collectParams(parts));
|
||||
|
||||
return parameters;
|
||||
}
|
||||
|
||||
this.updateParameters = function() {
|
||||
if (this.query.query === this.cachedQueryText) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.cachedQueryText = this.query.query;
|
||||
var parameterNames = this.parseQuery();
|
||||
|
||||
this.query.options.parameters = this.query.options.parameters || [];
|
||||
|
||||
var parametersMap = {};
|
||||
_.each(this.query.options.parameters, function(param) {
|
||||
parametersMap[param.name] = param;
|
||||
});
|
||||
|
||||
_.each(parameterNames, function(param) {
|
||||
if (!_.has(parametersMap, param)) {
|
||||
this.query.options.parameters.push({
|
||||
'title': param,
|
||||
'name': param,
|
||||
'type': 'text',
|
||||
'value': null
|
||||
});
|
||||
}
|
||||
}.bind(this));
|
||||
|
||||
this.query.options.parameters = _.filter(this.query.options.parameters, function(p) { return _.indexOf(parameterNames, p.name) !== -1});
|
||||
}
|
||||
|
||||
this.initFromQueryString = function() {
|
||||
var queryString = $location.search();
|
||||
_.each(this.get(), function(param) {
|
||||
var queryStringName = 'p_' + param.name;
|
||||
if (_.has(queryString, queryStringName)) {
|
||||
param.value = queryString[queryStringName];
|
||||
}
|
||||
});
|
||||
return parameters;
|
||||
};
|
||||
}
|
||||
|
||||
parameters = collectParams(parts);
|
||||
this.updateParameters();
|
||||
this.initFromQueryString();
|
||||
}
|
||||
|
||||
return parameters;
|
||||
Parameters.prototype.get = function() {
|
||||
this.updateParameters();
|
||||
return this.query.options.parameters;
|
||||
};
|
||||
|
||||
Parameters.prototype.getMissing = function() {
|
||||
return _.pluck(_.filter(this.get(), function(p) { return p.value === null || p.value === ''; }), 'title');
|
||||
}
|
||||
|
||||
Parameters.prototype.isRequired = function() {
|
||||
return !_.isEmpty(this.get());
|
||||
}
|
||||
|
||||
Parameters.prototype.getValues = function() {
|
||||
var params = this.get();
|
||||
return _.object(_.pluck(params, 'name'), _.pluck(params, 'value'));
|
||||
}
|
||||
|
||||
Query.prototype.getParameters = function() {
|
||||
if (!this.$parameters) {
|
||||
this.$parameters = new Parameters(this);
|
||||
}
|
||||
|
||||
return this.$parameters;
|
||||
}
|
||||
|
||||
Query.prototype.getParametersDefs = function() {
|
||||
return this.getParameters().get();
|
||||
}
|
||||
|
||||
return Query;
|
||||
};
|
||||
|
||||
|
||||
|
||||
var DataSource = function ($resource) {
|
||||
var actions = {
|
||||
'get': {'method': 'GET', 'cache': false, 'isArray': false},
|
||||
'query': {'method': 'GET', 'cache': false, 'isArray': true},
|
||||
'test': {'method': 'POST', 'cache': false, 'isArray': false, 'url': 'api/data_sources/:id/test'},
|
||||
'getSchema': {'method': 'GET', 'cache': true, 'isArray': true, 'url': 'api/data_sources/:id/schema'}
|
||||
};
|
||||
|
||||
@@ -565,6 +687,17 @@
|
||||
return DataSourceResource;
|
||||
};
|
||||
|
||||
var Destination = function ($resource) {
|
||||
var actions = {
|
||||
'get': {'method': 'GET', 'cache': false, 'isArray': false},
|
||||
'query': {'method': 'GET', 'cache': false, 'isArray': true}
|
||||
};
|
||||
|
||||
var DestinationResource = $resource('api/destinations/:id', {id: '@id'}, actions);
|
||||
|
||||
return DestinationResource;
|
||||
};
|
||||
|
||||
var User = function ($resource, $http) {
|
||||
var transformSingle = function(user) {
|
||||
if (user.groups !== undefined) {
|
||||
@@ -605,7 +738,7 @@
|
||||
};
|
||||
|
||||
var AlertSubscription = function ($resource) {
|
||||
var resource = $resource('api/alerts/:alertId/subscriptions/:userId', {alertId: '@alert_id', userId: '@user.id'});
|
||||
var resource = $resource('api/alerts/:alertId/subscriptions/:subscriberId', {alertId: '@alert_id', subscriberId: '@id'});
|
||||
return resource;
|
||||
};
|
||||
|
||||
@@ -617,7 +750,9 @@
|
||||
var newData = _.extend({}, data);
|
||||
if (newData.query_id === undefined) {
|
||||
newData.query_id = newData.query.id;
|
||||
newData.destination_id = newData.destinations;
|
||||
delete newData.query;
|
||||
delete newData.destinations;
|
||||
}
|
||||
|
||||
return newData;
|
||||
@@ -629,6 +764,24 @@
|
||||
return resource;
|
||||
};
|
||||
|
||||
var QuerySnippet = function ($resource) {
|
||||
var resource = $resource('api/query_snippets/:id', {id: '@id'});
|
||||
resource.prototype.getSnippet = function() {
|
||||
var name = this.trigger;
|
||||
if (this.description !== "") {
|
||||
name = this.trigger + ": " + this.description;
|
||||
}
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"content": this.snippet,
|
||||
"tabTrigger": this.trigger
|
||||
};
|
||||
}
|
||||
|
||||
return resource;
|
||||
};
|
||||
|
||||
var Widget = function ($resource, Query) {
|
||||
var WidgetResource = $resource('api/widgets/:id', {id: '@id'});
|
||||
|
||||
@@ -652,11 +805,13 @@
|
||||
|
||||
angular.module('redash.services')
|
||||
.factory('QueryResult', ['$resource', '$timeout', '$q', QueryResult])
|
||||
.factory('Query', ['$resource', 'QueryResult', 'DataSource', Query])
|
||||
.factory('Query', ['$resource', '$location', 'QueryResult', Query])
|
||||
.factory('DataSource', ['$resource', DataSource])
|
||||
.factory('Destination', ['$resource', Destination])
|
||||
.factory('Alert', ['$resource', '$http', Alert])
|
||||
.factory('AlertSubscription', ['$resource', AlertSubscription])
|
||||
.factory('Widget', ['$resource', 'Query', Widget])
|
||||
.factory('User', ['$resource', '$http', User])
|
||||
.factory('Group', ['$resource', Group]);
|
||||
.factory('Group', ['$resource', Group])
|
||||
.factory('QuerySnippet', ['$resource', QuerySnippet]);
|
||||
})();
|
||||
|
||||
505
rd_ui/app/scripts/vendor/cloud.js
vendored
Normal file
505
rd_ui/app/scripts/vendor/cloud.js
vendored
Normal file
@@ -0,0 +1,505 @@
|
||||
(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g=(g.d3||(g.d3 = {}));g=(g.layout||(g.layout = {}));g.cloud = f()}})(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o<r.length;o++)s(r[o]);return s})({1:[function(require,module,exports){
|
||||
// Word cloud layout by Jason Davies, https://www.jasondavies.com/wordcloud/
|
||||
// Algorithm due to Jonathan Feinberg, http://static.mrfeinberg.com/bv_ch03.pdf
|
||||
|
||||
var dispatch = require("d3-dispatch").dispatch;
|
||||
|
||||
var cloudRadians = Math.PI / 180,
|
||||
cw = 1 << 11 >> 5,
|
||||
ch = 1 << 11;
|
||||
|
||||
d3.cloud = function() {
|
||||
var size = [256, 256],
|
||||
text = cloudText,
|
||||
font = cloudFont,
|
||||
fontSize = cloudFontSize,
|
||||
fontStyle = cloudFontNormal,
|
||||
fontWeight = cloudFontNormal,
|
||||
rotate = cloudRotate,
|
||||
padding = cloudPadding,
|
||||
spiral = archimedeanSpiral,
|
||||
words = [],
|
||||
timeInterval = Infinity,
|
||||
event = dispatch("word", "end"),
|
||||
timer = null,
|
||||
random = Math.random,
|
||||
cloud = {},
|
||||
canvas = cloudCanvas;
|
||||
|
||||
cloud.canvas = function(_) {
|
||||
return arguments.length ? (canvas = functor(_), cloud) : canvas;
|
||||
};
|
||||
|
||||
cloud.start = function() {
|
||||
var contextAndRatio = getContext(canvas()),
|
||||
board = zeroArray((size[0] >> 5) * size[1]),
|
||||
bounds = null,
|
||||
n = words.length,
|
||||
i = -1,
|
||||
tags = [],
|
||||
data = words.map(function(d, i) {
|
||||
d.text = text.call(this, d, i);
|
||||
d.font = font.call(this, d, i);
|
||||
d.style = fontStyle.call(this, d, i);
|
||||
d.weight = fontWeight.call(this, d, i);
|
||||
d.rotate = rotate.call(this, d, i);
|
||||
d.size = ~~fontSize.call(this, d, i);
|
||||
d.padding = padding.call(this, d, i);
|
||||
return d;
|
||||
}).sort(function(a, b) { return b.size - a.size; });
|
||||
|
||||
if (timer) clearInterval(timer);
|
||||
timer = setInterval(step, 0);
|
||||
step();
|
||||
|
||||
return cloud;
|
||||
|
||||
function step() {
|
||||
var start = Date.now();
|
||||
while (Date.now() - start < timeInterval && ++i < n && timer) {
|
||||
var d = data[i];
|
||||
d.x = (size[0] * (random() + .5)) >> 1;
|
||||
d.y = (size[1] * (random() + .5)) >> 1;
|
||||
cloudSprite(contextAndRatio, d, data, i);
|
||||
if (d.hasText && place(board, d, bounds)) {
|
||||
tags.push(d);
|
||||
event.word(d);
|
||||
if (bounds) cloudBounds(bounds, d);
|
||||
else bounds = [{x: d.x + d.x0, y: d.y + d.y0}, {x: d.x + d.x1, y: d.y + d.y1}];
|
||||
// Temporary hack
|
||||
d.x -= size[0] >> 1;
|
||||
d.y -= size[1] >> 1;
|
||||
}
|
||||
}
|
||||
if (i >= n) {
|
||||
cloud.stop();
|
||||
event.end(tags, bounds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cloud.stop = function() {
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
return cloud;
|
||||
};
|
||||
|
||||
function getContext(canvas) {
|
||||
canvas.width = canvas.height = 1;
|
||||
var ratio = Math.sqrt(canvas.getContext("2d").getImageData(0, 0, 1, 1).data.length >> 2);
|
||||
canvas.width = (cw << 5) / ratio;
|
||||
canvas.height = ch / ratio;
|
||||
|
||||
var context = canvas.getContext("2d");
|
||||
context.fillStyle = context.strokeStyle = "red";
|
||||
context.textAlign = "center";
|
||||
|
||||
return {context: context, ratio: ratio};
|
||||
}
|
||||
|
||||
function place(board, tag, bounds) {
|
||||
var perimeter = [{x: 0, y: 0}, {x: size[0], y: size[1]}],
|
||||
startX = tag.x,
|
||||
startY = tag.y,
|
||||
maxDelta = Math.sqrt(size[0] * size[0] + size[1] * size[1]),
|
||||
s = spiral(size),
|
||||
dt = random() < .5 ? 1 : -1,
|
||||
t = -dt,
|
||||
dxdy,
|
||||
dx,
|
||||
dy;
|
||||
|
||||
while (dxdy = s(t += dt)) {
|
||||
dx = ~~dxdy[0];
|
||||
dy = ~~dxdy[1];
|
||||
|
||||
if (Math.min(Math.abs(dx), Math.abs(dy)) >= maxDelta) break;
|
||||
|
||||
tag.x = startX + dx;
|
||||
tag.y = startY + dy;
|
||||
|
||||
if (tag.x + tag.x0 < 0 || tag.y + tag.y0 < 0 ||
|
||||
tag.x + tag.x1 > size[0] || tag.y + tag.y1 > size[1]) continue;
|
||||
// TODO only check for collisions within current bounds.
|
||||
if (!bounds || !cloudCollide(tag, board, size[0])) {
|
||||
if (!bounds || collideRects(tag, bounds)) {
|
||||
var sprite = tag.sprite,
|
||||
w = tag.width >> 5,
|
||||
sw = size[0] >> 5,
|
||||
lx = tag.x - (w << 4),
|
||||
sx = lx & 0x7f,
|
||||
msx = 32 - sx,
|
||||
h = tag.y1 - tag.y0,
|
||||
x = (tag.y + tag.y0) * sw + (lx >> 5),
|
||||
last;
|
||||
for (var j = 0; j < h; j++) {
|
||||
last = 0;
|
||||
for (var i = 0; i <= w; i++) {
|
||||
board[x + i] |= (last << msx) | (i < w ? (last = sprite[j * w + i]) >>> sx : 0);
|
||||
}
|
||||
x += sw;
|
||||
}
|
||||
delete tag.sprite;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
cloud.timeInterval = function(_) {
|
||||
return arguments.length ? (timeInterval = _ == null ? Infinity : _, cloud) : timeInterval;
|
||||
};
|
||||
|
||||
cloud.words = function(_) {
|
||||
return arguments.length ? (words = _, cloud) : words;
|
||||
};
|
||||
|
||||
cloud.size = function(_) {
|
||||
return arguments.length ? (size = [+_[0], +_[1]], cloud) : size;
|
||||
};
|
||||
|
||||
cloud.font = function(_) {
|
||||
return arguments.length ? (font = functor(_), cloud) : font;
|
||||
};
|
||||
|
||||
cloud.fontStyle = function(_) {
|
||||
return arguments.length ? (fontStyle = functor(_), cloud) : fontStyle;
|
||||
};
|
||||
|
||||
cloud.fontWeight = function(_) {
|
||||
return arguments.length ? (fontWeight = functor(_), cloud) : fontWeight;
|
||||
};
|
||||
|
||||
cloud.rotate = function(_) {
|
||||
return arguments.length ? (rotate = functor(_), cloud) : rotate;
|
||||
};
|
||||
|
||||
cloud.text = function(_) {
|
||||
return arguments.length ? (text = functor(_), cloud) : text;
|
||||
};
|
||||
|
||||
cloud.spiral = function(_) {
|
||||
return arguments.length ? (spiral = spirals[_] || _, cloud) : spiral;
|
||||
};
|
||||
|
||||
cloud.fontSize = function(_) {
|
||||
return arguments.length ? (fontSize = functor(_), cloud) : fontSize;
|
||||
};
|
||||
|
||||
cloud.padding = function(_) {
|
||||
return arguments.length ? (padding = functor(_), cloud) : padding;
|
||||
};
|
||||
|
||||
cloud.random = function(_) {
|
||||
return arguments.length ? (random = _, cloud) : random;
|
||||
};
|
||||
|
||||
cloud.on = function() {
|
||||
var value = event.on.apply(event, arguments);
|
||||
return value === event ? cloud : value;
|
||||
};
|
||||
|
||||
return cloud;
|
||||
};
|
||||
|
||||
function cloudText(d) {
|
||||
return d.text;
|
||||
}
|
||||
|
||||
function cloudFont() {
|
||||
return "serif";
|
||||
}
|
||||
|
||||
function cloudFontNormal() {
|
||||
return "normal";
|
||||
}
|
||||
|
||||
function cloudFontSize(d) {
|
||||
return Math.sqrt(d.value);
|
||||
}
|
||||
|
||||
function cloudRotate() {
|
||||
return (~~(Math.random() * 6) - 3) * 30;
|
||||
}
|
||||
|
||||
function cloudPadding() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Fetches a monochrome sprite bitmap for the specified text.
|
||||
// Load in batches for speed.
|
||||
function cloudSprite(contextAndRatio, d, data, di) {
|
||||
if (d.sprite) return;
|
||||
var c = contextAndRatio.context,
|
||||
ratio = contextAndRatio.ratio;
|
||||
|
||||
c.clearRect(0, 0, (cw << 5) / ratio, ch / ratio);
|
||||
var x = 0,
|
||||
y = 0,
|
||||
maxh = 0,
|
||||
n = data.length;
|
||||
--di;
|
||||
while (++di < n) {
|
||||
d = data[di];
|
||||
c.save();
|
||||
c.font = d.style + " " + d.weight + " " + ~~((d.size + 1) / ratio) + "px " + d.font;
|
||||
var w = c.measureText(d.text + "m").width * ratio,
|
||||
h = d.size << 1;
|
||||
if (d.rotate) {
|
||||
var sr = Math.sin(d.rotate * cloudRadians),
|
||||
cr = Math.cos(d.rotate * cloudRadians),
|
||||
wcr = w * cr,
|
||||
wsr = w * sr,
|
||||
hcr = h * cr,
|
||||
hsr = h * sr;
|
||||
w = (Math.max(Math.abs(wcr + hsr), Math.abs(wcr - hsr)) + 0x1f) >> 5 << 5;
|
||||
h = ~~Math.max(Math.abs(wsr + hcr), Math.abs(wsr - hcr));
|
||||
} else {
|
||||
w = (w + 0x1f) >> 5 << 5;
|
||||
}
|
||||
if (h > maxh) maxh = h;
|
||||
if (x + w >= (cw << 5)) {
|
||||
x = 0;
|
||||
y += maxh;
|
||||
maxh = 0;
|
||||
}
|
||||
if (y + h >= ch) break;
|
||||
c.translate((x + (w >> 1)) / ratio, (y + (h >> 1)) / ratio);
|
||||
if (d.rotate) c.rotate(d.rotate * cloudRadians);
|
||||
c.fillText(d.text, 0, 0);
|
||||
if (d.padding) c.lineWidth = 2 * d.padding, c.strokeText(d.text, 0, 0);
|
||||
c.restore();
|
||||
d.width = w;
|
||||
d.height = h;
|
||||
d.xoff = x;
|
||||
d.yoff = y;
|
||||
d.x1 = w >> 1;
|
||||
d.y1 = h >> 1;
|
||||
d.x0 = -d.x1;
|
||||
d.y0 = -d.y1;
|
||||
d.hasText = true;
|
||||
x += w;
|
||||
}
|
||||
var pixels = c.getImageData(0, 0, (cw << 5) / ratio, ch / ratio).data,
|
||||
sprite = [];
|
||||
while (--di >= 0) {
|
||||
d = data[di];
|
||||
if (!d.hasText) continue;
|
||||
var w = d.width,
|
||||
w32 = w >> 5,
|
||||
h = d.y1 - d.y0;
|
||||
// Zero the buffer
|
||||
for (var i = 0; i < h * w32; i++) sprite[i] = 0;
|
||||
x = d.xoff;
|
||||
if (x == null) return;
|
||||
y = d.yoff;
|
||||
var seen = 0,
|
||||
seenRow = -1;
|
||||
for (var j = 0; j < h; j++) {
|
||||
for (var i = 0; i < w; i++) {
|
||||
var k = w32 * j + (i >> 5),
|
||||
m = pixels[((y + j) * (cw << 5) + (x + i)) << 2] ? 1 << (31 - (i % 32)) : 0;
|
||||
sprite[k] |= m;
|
||||
seen |= m;
|
||||
}
|
||||
if (seen) seenRow = j;
|
||||
else {
|
||||
d.y0++;
|
||||
h--;
|
||||
j--;
|
||||
y++;
|
||||
}
|
||||
}
|
||||
d.y1 = d.y0 + seenRow;
|
||||
d.sprite = sprite.slice(0, (d.y1 - d.y0) * w32);
|
||||
}
|
||||
}
|
||||
|
||||
// Use mask-based collision detection.
|
||||
function cloudCollide(tag, board, sw) {
|
||||
sw >>= 5;
|
||||
var sprite = tag.sprite,
|
||||
w = tag.width >> 5,
|
||||
lx = tag.x - (w << 4),
|
||||
sx = lx & 0x7f,
|
||||
msx = 32 - sx,
|
||||
h = tag.y1 - tag.y0,
|
||||
x = (tag.y + tag.y0) * sw + (lx >> 5),
|
||||
last;
|
||||
for (var j = 0; j < h; j++) {
|
||||
last = 0;
|
||||
for (var i = 0; i <= w; i++) {
|
||||
if (((last << msx) | (i < w ? (last = sprite[j * w + i]) >>> sx : 0))
|
||||
& board[x + i]) return true;
|
||||
}
|
||||
x += sw;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function cloudBounds(bounds, d) {
|
||||
var b0 = bounds[0],
|
||||
b1 = bounds[1];
|
||||
if (d.x + d.x0 < b0.x) b0.x = d.x + d.x0;
|
||||
if (d.y + d.y0 < b0.y) b0.y = d.y + d.y0;
|
||||
if (d.x + d.x1 > b1.x) b1.x = d.x + d.x1;
|
||||
if (d.y + d.y1 > b1.y) b1.y = d.y + d.y1;
|
||||
}
|
||||
|
||||
function collideRects(a, b) {
|
||||
return a.x + a.x1 > b[0].x && a.x + a.x0 < b[1].x && a.y + a.y1 > b[0].y && a.y + a.y0 < b[1].y;
|
||||
}
|
||||
|
||||
function archimedeanSpiral(size) {
|
||||
var e = size[0] / size[1];
|
||||
return function(t) {
|
||||
return [e * (t *= .1) * Math.cos(t), t * Math.sin(t)];
|
||||
};
|
||||
}
|
||||
|
||||
function rectangularSpiral(size) {
|
||||
var dy = 4,
|
||||
dx = dy * size[0] / size[1],
|
||||
x = 0,
|
||||
y = 0;
|
||||
return function(t) {
|
||||
var sign = t < 0 ? -1 : 1;
|
||||
// See triangular numbers: T_n = n * (n + 1) / 2.
|
||||
switch ((Math.sqrt(1 + 4 * sign * t) - sign) & 3) {
|
||||
case 0: x += dx; break;
|
||||
case 1: y += dy; break;
|
||||
case 2: x -= dx; break;
|
||||
default: y -= dy; break;
|
||||
}
|
||||
return [x, y];
|
||||
};
|
||||
}
|
||||
|
||||
// TODO reuse arrays?
|
||||
function zeroArray(n) {
|
||||
var a = [],
|
||||
i = -1;
|
||||
while (++i < n) a[i] = 0;
|
||||
return a;
|
||||
}
|
||||
|
||||
function cloudCanvas() {
|
||||
return document.createElement("canvas");
|
||||
}
|
||||
|
||||
function functor(d) {
|
||||
return typeof d === "function" ? d : function() { return d; };
|
||||
}
|
||||
|
||||
var spirals = {
|
||||
archimedean: archimedeanSpiral,
|
||||
rectangular: rectangularSpiral
|
||||
};
|
||||
|
||||
},{"d3-dispatch":2}],2:[function(require,module,exports){
|
||||
(function (global, factory) {
|
||||
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
|
||||
typeof define === 'function' && define.amd ? define(['exports'], factory) :
|
||||
factory((global.dispatch = {}));
|
||||
}(this, function (exports) { 'use strict';
|
||||
|
||||
function Dispatch(types) {
|
||||
var i = -1,
|
||||
n = types.length,
|
||||
callbacksByType = {},
|
||||
callbackByName = {},
|
||||
type,
|
||||
that = this;
|
||||
|
||||
that.on = function(type, callback) {
|
||||
type = parseType(type);
|
||||
|
||||
// Return the current callback, if any.
|
||||
if (arguments.length < 2) {
|
||||
return (callback = callbackByName[type.name]) && callback.value;
|
||||
}
|
||||
|
||||
// If a type was specified…
|
||||
if (type.type) {
|
||||
var callbacks = callbacksByType[type.type],
|
||||
callback0 = callbackByName[type.name],
|
||||
i;
|
||||
|
||||
// Remove the current callback, if any, using copy-on-remove.
|
||||
if (callback0) {
|
||||
callback0.value = null;
|
||||
i = callbacks.indexOf(callback0);
|
||||
callbacksByType[type.type] = callbacks = callbacks.slice(0, i).concat(callbacks.slice(i + 1));
|
||||
delete callbackByName[type.name];
|
||||
}
|
||||
|
||||
// Add the new callback, if any.
|
||||
if (callback) {
|
||||
callback = {value: callback};
|
||||
callbackByName[type.name] = callback;
|
||||
callbacks.push(callback);
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, if a null callback was specified, remove all callbacks with the given name.
|
||||
else if (callback == null) {
|
||||
for (var otherType in callbacksByType) {
|
||||
if (callback = callbackByName[otherType + type.name]) {
|
||||
callback.value = null;
|
||||
var callbacks = callbacksByType[otherType], i = callbacks.indexOf(callback);
|
||||
callbacksByType[otherType] = callbacks.slice(0, i).concat(callbacks.slice(i + 1));
|
||||
delete callbackByName[callback.name];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return that;
|
||||
};
|
||||
|
||||
while (++i < n) {
|
||||
type = types[i] + "";
|
||||
if (!type || (type in that)) throw new Error("illegal or duplicate type: " + type);
|
||||
callbacksByType[type] = [];
|
||||
that[type] = applier(type);
|
||||
}
|
||||
|
||||
function parseType(type) {
|
||||
var i = (type += "").indexOf("."), name = type;
|
||||
if (i >= 0) type = type.slice(0, i); else name += ".";
|
||||
if (type && !callbacksByType.hasOwnProperty(type)) throw new Error("unknown type: " + type);
|
||||
return {type: type, name: name};
|
||||
}
|
||||
|
||||
function applier(type) {
|
||||
return function() {
|
||||
var callbacks = callbacksByType[type], // Defensive reference; copy-on-remove.
|
||||
callback,
|
||||
callbackValue,
|
||||
i = -1,
|
||||
n = callbacks.length;
|
||||
|
||||
while (++i < n) {
|
||||
if (callbackValue = (callback = callbacks[i]).value) {
|
||||
callbackValue.apply(this, arguments);
|
||||
}
|
||||
}
|
||||
|
||||
return that;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function dispatch() {
|
||||
return new Dispatch(arguments);
|
||||
}
|
||||
|
||||
dispatch.prototype = Dispatch.prototype; // allow instanceof
|
||||
|
||||
exports.dispatch = dispatch;
|
||||
|
||||
}));
|
||||
},{}]},{},[1])(1)
|
||||
});
|
||||
294
rd_ui/app/scripts/vendor/d3.sankey.js
vendored
Normal file
294
rd_ui/app/scripts/vendor/d3.sankey.js
vendored
Normal file
@@ -0,0 +1,294 @@
|
||||
d3.sankey = function() {
|
||||
var sankey = {},
|
||||
nodeWidth = 24,
|
||||
nodePadding = 8,
|
||||
size = [1, 1],
|
||||
nodes = [],
|
||||
links = [];
|
||||
|
||||
sankey.nodeWidth = function(_) {
|
||||
if (!arguments.length) return nodeWidth;
|
||||
nodeWidth = +_;
|
||||
return sankey;
|
||||
};
|
||||
|
||||
sankey.nodePadding = function(_) {
|
||||
if (!arguments.length) return nodePadding;
|
||||
nodePadding = +_;
|
||||
return sankey;
|
||||
};
|
||||
|
||||
sankey.nodes = function(_) {
|
||||
if (!arguments.length) return nodes;
|
||||
nodes = _;
|
||||
return sankey;
|
||||
};
|
||||
|
||||
sankey.links = function(_) {
|
||||
if (!arguments.length) return links;
|
||||
links = _;
|
||||
return sankey;
|
||||
};
|
||||
|
||||
sankey.size = function(_) {
|
||||
if (!arguments.length) return size;
|
||||
size = _;
|
||||
return sankey;
|
||||
};
|
||||
|
||||
sankey.layout = function(iterations) {
|
||||
computeNodeLinks();
|
||||
computeNodeValues();
|
||||
computeNodeBreadths();
|
||||
computeNodeDepths(iterations);
|
||||
computeLinkDepths();
|
||||
return sankey;
|
||||
};
|
||||
|
||||
sankey.relayout = function() {
|
||||
computeLinkDepths();
|
||||
return sankey;
|
||||
};
|
||||
|
||||
sankey.link = function() {
|
||||
var curvature = .5;
|
||||
|
||||
function link(d) {
|
||||
var x0 = d.source.x + d.source.dx,
|
||||
x1 = d.target.x,
|
||||
xi = d3.interpolateNumber(x0, x1),
|
||||
x2 = xi(curvature),
|
||||
x3 = xi(1 - curvature),
|
||||
y0 = d.source.y + d.sy + d.dy / 2,
|
||||
y1 = d.target.y + d.ty + d.dy / 2;
|
||||
return "M" + x0 + "," + y0
|
||||
+ "C" + x2 + "," + y0
|
||||
+ " " + x3 + "," + y1
|
||||
+ " " + x1 + "," + y1;
|
||||
}
|
||||
|
||||
link.curvature = function(_) {
|
||||
if (!arguments.length) return curvature;
|
||||
curvature = +_;
|
||||
return link;
|
||||
};
|
||||
|
||||
return link;
|
||||
};
|
||||
|
||||
// Populate the sourceLinks and targetLinks for each node.
|
||||
// Also, if the source and target are not objects, assume they are indices.
|
||||
function computeNodeLinks() {
|
||||
nodes.forEach(function(node) {
|
||||
node.sourceLinks = [];
|
||||
node.targetLinks = [];
|
||||
});
|
||||
links.forEach(function(link) {
|
||||
var source = link.source,
|
||||
target = link.target;
|
||||
if (typeof source === "number") source = link.source = nodes[link.source];
|
||||
if (typeof target === "number") target = link.target = nodes[link.target];
|
||||
source.sourceLinks.push(link);
|
||||
target.targetLinks.push(link);
|
||||
});
|
||||
}
|
||||
|
||||
// Compute the value (size) of each node by summing the associated links.
|
||||
function computeNodeValues() {
|
||||
nodes.forEach(function(node) {
|
||||
node.value = Math.max(
|
||||
d3.sum(node.sourceLinks, value),
|
||||
d3.sum(node.targetLinks, value)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Iteratively assign the breadth (x-position) for each node.
|
||||
// Nodes are assigned the maximum breadth of incoming neighbors plus one;
|
||||
// nodes with no incoming links are assigned breadth zero, while
|
||||
// nodes with no outgoing links are assigned the maximum breadth.
|
||||
function computeNodeBreadths() {
|
||||
var remainingNodes = nodes,
|
||||
nextNodes,
|
||||
x = 0;
|
||||
|
||||
while (remainingNodes.length) {
|
||||
nextNodes = [];
|
||||
remainingNodes.forEach(function(node) {
|
||||
node.x = x;
|
||||
node.dx = nodeWidth;
|
||||
node.sourceLinks.forEach(function(link) {
|
||||
if (nextNodes.indexOf(link.target) < 0) {
|
||||
nextNodes.push(link.target);
|
||||
}
|
||||
});
|
||||
});
|
||||
remainingNodes = nextNodes;
|
||||
++x;
|
||||
}
|
||||
|
||||
//
|
||||
moveSinksRight(x);
|
||||
scaleNodeBreadths((size[0] - nodeWidth) / (x - 1));
|
||||
}
|
||||
|
||||
function moveSourcesRight() {
|
||||
nodes.forEach(function(node) {
|
||||
if (!node.targetLinks.length) {
|
||||
node.x = d3.min(node.sourceLinks, function(d) { return d.target.x; }) - 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function moveSinksRight(x) {
|
||||
nodes.forEach(function(node) {
|
||||
if (!node.sourceLinks.length) {
|
||||
node.x = x - 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function scaleNodeBreadths(kx) {
|
||||
nodes.forEach(function(node) {
|
||||
node.x *= kx;
|
||||
});
|
||||
}
|
||||
|
||||
function computeNodeDepths(iterations) {
|
||||
var nodesByBreadth = d3.nest()
|
||||
.key(function(d) { return d.x; })
|
||||
.sortKeys(d3.ascending)
|
||||
.entries(nodes)
|
||||
.map(function(d) { return d.values; });
|
||||
|
||||
//
|
||||
initializeNodeDepth();
|
||||
resolveCollisions();
|
||||
for (var alpha = 1; iterations > 0; --iterations) {
|
||||
relaxRightToLeft(alpha *= .99);
|
||||
resolveCollisions();
|
||||
relaxLeftToRight(alpha);
|
||||
resolveCollisions();
|
||||
}
|
||||
|
||||
function initializeNodeDepth() {
|
||||
var ky = d3.min(nodesByBreadth, function(nodes) {
|
||||
return (size[1] - (nodes.length - 1) * nodePadding) / d3.sum(nodes, value);
|
||||
});
|
||||
|
||||
nodesByBreadth.forEach(function(nodes) {
|
||||
nodes.forEach(function(node, i) {
|
||||
node.y = i;
|
||||
node.dy = node.value * ky;
|
||||
});
|
||||
});
|
||||
|
||||
links.forEach(function(link) {
|
||||
link.dy = link.value * ky;
|
||||
});
|
||||
}
|
||||
|
||||
function relaxLeftToRight(alpha) {
|
||||
nodesByBreadth.forEach(function(nodes, breadth) {
|
||||
nodes.forEach(function(node) {
|
||||
if (node.targetLinks.length) {
|
||||
var y = d3.sum(node.targetLinks, weightedSource) / d3.sum(node.targetLinks, value);
|
||||
node.y += (y - center(node)) * alpha;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function weightedSource(link) {
|
||||
return center(link.source) * link.value;
|
||||
}
|
||||
}
|
||||
|
||||
function relaxRightToLeft(alpha) {
|
||||
nodesByBreadth.slice().reverse().forEach(function(nodes) {
|
||||
nodes.forEach(function(node) {
|
||||
if (node.sourceLinks.length) {
|
||||
var y = d3.sum(node.sourceLinks, weightedTarget) / d3.sum(node.sourceLinks, value);
|
||||
node.y += (y - center(node)) * alpha;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function weightedTarget(link) {
|
||||
return center(link.target) * link.value;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCollisions() {
|
||||
nodesByBreadth.forEach(function(nodes) {
|
||||
var node,
|
||||
dy,
|
||||
y0 = 0,
|
||||
n = nodes.length,
|
||||
i;
|
||||
|
||||
// Push any overlapping nodes down.
|
||||
nodes.sort(ascendingDepth);
|
||||
for (i = 0; i < n; ++i) {
|
||||
node = nodes[i];
|
||||
dy = y0 - node.y;
|
||||
if (dy > 0) node.y += dy;
|
||||
y0 = node.y + node.dy + nodePadding;
|
||||
}
|
||||
|
||||
// If the bottommost node goes outside the bounds, push it back up.
|
||||
dy = y0 - nodePadding - size[1];
|
||||
if (dy > 0) {
|
||||
y0 = node.y -= dy;
|
||||
|
||||
// Push any overlapping nodes back up.
|
||||
for (i = n - 2; i >= 0; --i) {
|
||||
node = nodes[i];
|
||||
dy = node.y + node.dy + nodePadding - y0;
|
||||
if (dy > 0) node.y -= dy;
|
||||
y0 = node.y;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function ascendingDepth(a, b) {
|
||||
return a.y - b.y;
|
||||
}
|
||||
}
|
||||
|
||||
function computeLinkDepths() {
|
||||
nodes.forEach(function(node) {
|
||||
node.sourceLinks.sort(ascendingTargetDepth);
|
||||
node.targetLinks.sort(ascendingSourceDepth);
|
||||
});
|
||||
nodes.forEach(function(node) {
|
||||
var sy = 0, ty = 0;
|
||||
node.sourceLinks.forEach(function(link) {
|
||||
link.sy = sy;
|
||||
sy += link.dy;
|
||||
});
|
||||
node.targetLinks.forEach(function(link) {
|
||||
link.ty = ty;
|
||||
ty += link.dy;
|
||||
});
|
||||
});
|
||||
|
||||
function ascendingSourceDepth(a, b) {
|
||||
return a.source.y - b.source.y;
|
||||
}
|
||||
|
||||
function ascendingTargetDepth(a, b) {
|
||||
return a.target.y - b.target.y;
|
||||
}
|
||||
}
|
||||
|
||||
function center(node) {
|
||||
return node.y + node.dy / 2;
|
||||
}
|
||||
|
||||
function value(link) {
|
||||
return link.value;
|
||||
}
|
||||
|
||||
return sankey;
|
||||
};
|
||||
@@ -167,7 +167,7 @@
|
||||
scope.$watch('visualization.type', function (type, oldType) {
|
||||
// if not edited by user, set name to match type
|
||||
if (type && oldType !== type && scope.visualization && !scope.visForm.name.$dirty) {
|
||||
scope.visualization.name = _.string.titleize(scope.visualization.type);
|
||||
scope.visualization.name = Visualization.visualizations[scope.visualization.type].name;
|
||||
}
|
||||
|
||||
if (type && oldType !== type && scope.visualization) {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
'</boxplot-renderer>';
|
||||
|
||||
var editTemplate = '<boxplot-editor></boxplot-editor>';
|
||||
|
||||
|
||||
VisualizationProvider.registerVisualization({
|
||||
type: 'BOXPLOT',
|
||||
name: 'Boxplot',
|
||||
@@ -37,7 +37,10 @@
|
||||
};
|
||||
|
||||
$scope.$watch('[queryResult && queryResult.getData(), visualization.options]', function () {
|
||||
|
||||
if ($scope.queryResult.getData() === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
var data = $scope.queryResult.getData();
|
||||
var parentWidth = d3.select(elm[0].parentNode).node().getBoundingClientRect().width;
|
||||
var margin = {top: 10, right: 50, bottom: 40, left: 50, inner: 25},
|
||||
@@ -52,7 +55,7 @@
|
||||
var xAxisLabel = $scope.visualization.options.xAxisLabel;
|
||||
var yAxisLabel = $scope.visualization.options.yAxisLabel;
|
||||
|
||||
var columns = $scope.queryResult.columnNames;
|
||||
var columns = $scope.queryResult.getColumnNames();
|
||||
var xscale = d3.scale.ordinal()
|
||||
.domain(columns)
|
||||
.rangeBands([0, parentWidth-margin.left-margin.right]);
|
||||
@@ -82,7 +85,7 @@
|
||||
.whiskers(iqr(1.5))
|
||||
.width(boxWidth-2*margin.inner)
|
||||
.height(height)
|
||||
.domain([min*0.99,max*1.01]);
|
||||
.domain([min*0.99,max*1.01]);
|
||||
var xAxis = d3.svg.axis()
|
||||
.scale(xscale)
|
||||
.orient("bottom");
|
||||
@@ -132,7 +135,7 @@
|
||||
plot.append("rect")
|
||||
.attr("class", "grid-background")
|
||||
.attr("width", width)
|
||||
.attr("height", height);
|
||||
.attr("height", height);
|
||||
|
||||
plot.append("g")
|
||||
.attr("class","grid")
|
||||
@@ -157,7 +160,7 @@
|
||||
.attr("width", boxWidth)
|
||||
.attr("height", height)
|
||||
.attr("transform", function(d,i) { return "translate(" + barOffset(i) + "," + 0 + ")"; } )
|
||||
.call(chart);
|
||||
.call(chart);
|
||||
}, true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
xAxis: {type: 'datetime', labels: {enabled: true}},
|
||||
series: {stacking: null},
|
||||
seriesOptions: {},
|
||||
columnMapping: {}
|
||||
columnMapping: {},
|
||||
bottomMargin: 50
|
||||
};
|
||||
|
||||
VisualizationProvider.registerVisualization({
|
||||
@@ -93,7 +94,7 @@
|
||||
};
|
||||
|
||||
scope.xAxisScales = ['datetime', 'linear', 'logarithmic', 'category'];
|
||||
scope.yAxisScales = ['linear', 'logarithmic'];
|
||||
scope.yAxisScales = ['linear', 'logarithmic', 'datetime'];
|
||||
|
||||
var refreshColumns = function() {
|
||||
scope.columns = scope.queryResult.getColumns();
|
||||
@@ -194,6 +195,10 @@
|
||||
scope.options.legend = {enabled: true};
|
||||
}
|
||||
|
||||
if (!_.has(scope.options, 'bottomMargin')) {
|
||||
scope.options.bottomMargin = 50;
|
||||
}
|
||||
|
||||
if (scope.columnNames)
|
||||
_.each(scope.options.columnMapping, function(value, key) {
|
||||
if (scope.columnNames.length > 0 && !_.contains(scope.columnNames, key))
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
(function (window) {
|
||||
var module = angular.module('redash.visualization');
|
||||
|
||||
module.directive('dateRangeSelector', [function () {
|
||||
return {
|
||||
restrict: 'E',
|
||||
scope: {
|
||||
dateRange: "="
|
||||
},
|
||||
templateUrl: '/views/visualizations/date_range_selector.html',
|
||||
replace: true,
|
||||
controller: ['$scope', function ($scope) {
|
||||
$scope.dateRangeHuman = {
|
||||
min: null,
|
||||
max: null
|
||||
};
|
||||
|
||||
$scope.$watch('dateRange', function (dateRange, oldDateRange, scope) {
|
||||
scope.dateRangeHuman.min = dateRange.min.format('YYYY-MM-DD');
|
||||
scope.dateRangeHuman.max = dateRange.max.format('YYYY-MM-DD');
|
||||
});
|
||||
|
||||
$scope.$watch('dateRangeHuman', function (dateRangeHuman, oldDateRangeHuman, scope) {
|
||||
var newDateRangeMin = moment.utc(dateRangeHuman.min);
|
||||
var newDateRangeMax = moment.utc(dateRangeHuman.max);
|
||||
if (!newDateRangeMin ||
|
||||
!newDateRangeMax ||
|
||||
!newDateRangeMin.isValid() ||
|
||||
!newDateRangeMax.isValid() ||
|
||||
newDateRangeMin.isAfter(newDateRangeMax)) {
|
||||
// Prevent invalid date input
|
||||
// No need to show up a notification to user here, it will be too noisy.
|
||||
// Instead, simply preventing changes to the scope silently.
|
||||
scope.dateRangeHuman = oldDateRangeHuman;
|
||||
return;
|
||||
}
|
||||
scope.dateRange.min = newDateRangeMin;
|
||||
scope.dateRange.max = newDateRangeMax;
|
||||
}, true);
|
||||
}]
|
||||
}
|
||||
}]);
|
||||
})(window);
|
||||
@@ -11,9 +11,9 @@
|
||||
|
||||
var editTemplate = '<map-editor></map-editor>';
|
||||
var defaultOptions = {
|
||||
'height': 500,
|
||||
'draw': 'Marker',
|
||||
'classify':'none'
|
||||
height: 500,
|
||||
classify: 'none',
|
||||
clusterMarkers: true
|
||||
};
|
||||
|
||||
VisualizationProvider.registerVisualization({
|
||||
@@ -31,195 +31,200 @@
|
||||
restrict: 'E',
|
||||
templateUrl: '/views/visualizations/map.html',
|
||||
link: function($scope, elm, attrs) {
|
||||
$scope.$watch('queryResult && queryResult.getData()', render, true);
|
||||
$scope.$watch('visualization.options', render, true);
|
||||
angular.element(window).on("resize", resize);
|
||||
$scope.$watch('visualization.options.height', resize);
|
||||
|
||||
var setBounds = function(){
|
||||
var color = d3.scale.category10();
|
||||
var map = L.map(elm[0].children[0].children[0], {scrollWheelZoom: false});
|
||||
var mapControls = L.control.layers().addTo(map);
|
||||
var layers = {};
|
||||
var tileLayer = L.tileLayer('//{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="http://osm.org/copyright">OpenStreetMap</a> contributors'
|
||||
}).addTo(map);
|
||||
|
||||
map.on('focus',function(){
|
||||
map.on('moveend', getBounds);
|
||||
});
|
||||
|
||||
map.on('blur',function(){
|
||||
map.off('moveend', getBounds);
|
||||
});
|
||||
|
||||
// Following line is used to avoid "Couldn't autodetect L.Icon.Default.imagePath" error
|
||||
// https://github.com/Leaflet/Leaflet/issues/766#issuecomment-7741039
|
||||
L.Icon.Default.imagePath = L.Icon.Default.imagePath || "//api.tiles.mapbox.com/mapbox.js/v2.2.1/images";
|
||||
|
||||
|
||||
function resize() {
|
||||
if (!map) return;
|
||||
map.invalidateSize(false);
|
||||
setBounds();
|
||||
}
|
||||
|
||||
function setBounds (){
|
||||
var b = $scope.visualization.options.bounds;
|
||||
|
||||
if(b){
|
||||
$scope.map.fitBounds([[b._southWest.lat, b._southWest.lng],[b._northEast.lat, b._northEast.lng]]);
|
||||
} else if ($scope.features.length > 0){
|
||||
var group= new L.featureGroup($scope.features);
|
||||
$scope.map.fitBounds(group.getBounds());
|
||||
map.fitBounds([[b._southWest.lat, b._southWest.lng],[b._northEast.lat, b._northEast.lng]]);
|
||||
} else if (layers){
|
||||
var allMarkers = _.flatten(_.map(_.values(layers), function(l) { return l.getLayers() }));
|
||||
var group = new L.featureGroup(allMarkers);
|
||||
map.fitBounds(group.getBounds());
|
||||
}
|
||||
};
|
||||
|
||||
$scope.$watch('[queryResult && queryResult.getData(), visualization.options.draw,visualization.options.latColName,'+
|
||||
'visualization.options.lonColName,visualization.options.classify,visualization.options.classify]',
|
||||
function() {
|
||||
var marker = function(lat,lon){
|
||||
if (lat == null || lon == null) return;
|
||||
var createMarker = function(lat,lon){
|
||||
if (lat == null || lon == null) return;
|
||||
|
||||
return L.marker([lat, lon]);
|
||||
};
|
||||
return L.marker([lat, lon]);
|
||||
};
|
||||
|
||||
var heatpoint = function(lat,lon,obj){
|
||||
if (lat == null || lon == null) return;
|
||||
var heatpoint = function(lat, lon, color){
|
||||
if (lat == null || lon == null) return;
|
||||
|
||||
var color = 'red';
|
||||
var style = {
|
||||
fillColor:color,
|
||||
fillOpacity:0.9,
|
||||
stroke:false
|
||||
};
|
||||
|
||||
if (obj &&
|
||||
obj[$scope.visualization.options.classify] &&
|
||||
$scope.visualization.options.classification){
|
||||
var v = $.grep($scope.visualization.options.classification,function(e){
|
||||
return e.value == obj[$scope.visualization.options.classify];
|
||||
});
|
||||
if (v.length >0) color = v[0].color;
|
||||
}
|
||||
return L.circleMarker([lat,lon],style)
|
||||
};
|
||||
|
||||
var style = {
|
||||
fillColor:color,
|
||||
fillOpacity:0.5,
|
||||
stroke:false
|
||||
};
|
||||
function getBounds() {
|
||||
$scope.visualization.options.bounds = map.getBounds();
|
||||
}
|
||||
|
||||
return L.circleMarker([lat,lon],style)
|
||||
};
|
||||
function createDescription(latCol, lonCol, row) {
|
||||
var lat = row[latCol];
|
||||
var lon = row[lonCol];
|
||||
|
||||
var color = function(val){
|
||||
// taken from http://jsfiddle.net/xgJ2e/2/
|
||||
var description = '<ul style="list-style-type: none;padding-left: 0">';
|
||||
description += "<li><strong>"+lat+ ", " + lon + "</strong>";
|
||||
|
||||
var h= Math.floor((100 - val) * 120 / 100);
|
||||
var s = Math.abs(val - 50)/50;
|
||||
var v = 1;
|
||||
for (var k in row){
|
||||
if (!(k == latCol || k == lonCol)) {
|
||||
description += "<li>" + k + ": " + row[k] + "</li>";
|
||||
}
|
||||
}
|
||||
|
||||
var rgb, i, data = [];
|
||||
if (s === 0) {
|
||||
rgb = [v,v,v];
|
||||
} else {
|
||||
h = h / 60;
|
||||
i = Math.floor(h);
|
||||
data = [v*(1-s), v*(1-s*(h-i)), v*(1-s*(1-(h-i)))];
|
||||
switch(i) {
|
||||
case 0:
|
||||
rgb = [v, data[2], data[0]];
|
||||
break;
|
||||
case 1:
|
||||
rgb = [data[1], v, data[0]];
|
||||
break;
|
||||
case 2:
|
||||
rgb = [data[0], v, data[2]];
|
||||
break;
|
||||
case 3:
|
||||
rgb = [data[0], data[1], v];
|
||||
break;
|
||||
case 4:
|
||||
rgb = [data[2], data[0], v];
|
||||
break;
|
||||
default:
|
||||
rgb = [v, data[0], data[1]];
|
||||
break;
|
||||
return description;
|
||||
}
|
||||
|
||||
function removeLayer(layer) {
|
||||
if (layer) {
|
||||
mapControls.removeLayer(layer);
|
||||
map.removeLayer(layer);
|
||||
}
|
||||
}
|
||||
|
||||
function addLayer(name, points) {
|
||||
var latCol = $scope.visualization.options.latColName || 'lat';
|
||||
var lonCol = $scope.visualization.options.lonColName || 'lon';
|
||||
var classify = $scope.visualization.options.classify;
|
||||
|
||||
var markers;
|
||||
if ($scope.visualization.options.clusterMarkers) {
|
||||
var color = $scope.visualization.options.groups[name].color;
|
||||
var options = {};
|
||||
|
||||
if (classify) {
|
||||
options.iconCreateFunction = function (cluster) {
|
||||
var childCount = cluster.getChildCount();
|
||||
|
||||
var c = ' marker-cluster-';
|
||||
if (childCount < 10) {
|
||||
c += 'small';
|
||||
} else if (childCount < 100) {
|
||||
c += 'medium';
|
||||
} else {
|
||||
c += 'large';
|
||||
}
|
||||
|
||||
c = '';
|
||||
|
||||
|
||||
var style = 'color: white; background-color: '+color+';';
|
||||
|
||||
return L.divIcon({ html: '<div style="'+style+'"><span>' + childCount + '</span></div>', className: 'marker-cluster' + c, iconSize: new L.Point(40, 40) });
|
||||
}
|
||||
return '#' + rgb.map(function(x){
|
||||
return ("0" + Math.round(x*255).toString(16)).slice(-2);
|
||||
}).join('');
|
||||
};
|
||||
|
||||
// Following line is used to avoid "Couldn't autodetect L.Icon.Default.imagePath" error
|
||||
// https://github.com/Leaflet/Leaflet/issues/766#issuecomment-7741039
|
||||
L.Icon.Default.imagePath = L.Icon.Default.imagePath || "//api.tiles.mapbox.com/mapbox.js/v2.2.1/images";
|
||||
|
||||
function getBounds(e) {
|
||||
$scope.visualization.options.bounds = $scope.map.getBounds();
|
||||
}
|
||||
|
||||
var queryData = $scope.queryResult.getData();
|
||||
var classify = $scope.visualization.options.classify;
|
||||
markers = L.markerClusterGroup(options);
|
||||
} else {
|
||||
markers = L.layerGroup();
|
||||
}
|
||||
|
||||
if (queryData) {
|
||||
$scope.visualization.options.classification = [];
|
||||
// create markers
|
||||
_.each(points, function(row) {
|
||||
var marker;
|
||||
|
||||
for (var row in queryData) {
|
||||
if (queryData[row][classify] &&
|
||||
$.grep($scope.visualization.options.classification, function (e) {
|
||||
return e.value == queryData[row][classify]
|
||||
}).length == 0) {
|
||||
$scope.visualization.options.classification.push({value: queryData[row][classify], color: null});
|
||||
}
|
||||
}
|
||||
|
||||
$.each($scope.visualization.options.classification, function (i, c) {
|
||||
c.color = color(parseInt((i / $scope.visualization.options.classification.length) * 100));
|
||||
});
|
||||
|
||||
if (!$scope.map) {
|
||||
$scope.map = L.map(elm[0].children[0].children[0])
|
||||
}
|
||||
|
||||
L.tileLayer('//{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="http://osm.org/copyright">OpenStreetMap</a> contributors'
|
||||
}).addTo($scope.map);
|
||||
|
||||
$scope.features = $scope.features || [];
|
||||
|
||||
var tmp_features = [];
|
||||
|
||||
var lat_col = $scope.visualization.options.latColName || 'lat';
|
||||
var lon_col = $scope.visualization.options.lonColName || 'lon';
|
||||
|
||||
for (var row in queryData) {
|
||||
var feature;
|
||||
|
||||
if ($scope.visualization.options.draw == 'Marker') {
|
||||
feature = marker(queryData[row][lat_col], queryData[row][lon_col])
|
||||
} else if ($scope.visualization.options.draw == 'Color') {
|
||||
feature = heatpoint(queryData[row][lat_col], queryData[row][lon_col], queryData[row])
|
||||
}
|
||||
|
||||
if (!feature) continue;
|
||||
|
||||
var obj_description = '<ul style="list-style-type: none;padding-left: 0">';
|
||||
for (var k in queryData[row]){
|
||||
obj_description += "<li>" + k + ": " + queryData[row][k] + "</li>";
|
||||
}
|
||||
obj_description += '</ul>';
|
||||
feature.bindPopup(obj_description);
|
||||
tmp_features.push(feature);
|
||||
}
|
||||
|
||||
$.each($scope.features, function (i, f) {
|
||||
$scope.map.removeLayer(f);
|
||||
});
|
||||
|
||||
$scope.features = tmp_features;
|
||||
|
||||
$.each($scope.features, function (i, f) {
|
||||
f.addTo($scope.map)
|
||||
});
|
||||
|
||||
setBounds();
|
||||
|
||||
$scope.map.on('focus',function(){
|
||||
$scope.map.on('moveend', getBounds);
|
||||
});
|
||||
|
||||
$scope.map.on('blur',function(){
|
||||
$scope.map.off('moveend', getBounds);
|
||||
});
|
||||
|
||||
|
||||
// We redraw the map if it was loaded in a hidden tab
|
||||
if ($('a[href="#'+$scope.visualization.id+'"]').length > 0) {
|
||||
|
||||
$('a[href="#'+$scope.visualization.id+'"]').on('click', function () {
|
||||
setTimeout(function() {
|
||||
$scope.map.invalidateSize(false);
|
||||
|
||||
setBounds();
|
||||
},500);
|
||||
});
|
||||
}
|
||||
var lat = row[latCol];
|
||||
var lon = row[lonCol];
|
||||
|
||||
if (classify && classify != 'none') {
|
||||
var color = $scope.visualization.options.groups[name].color;
|
||||
marker = heatpoint(lat, lon, color);
|
||||
} else {
|
||||
marker = createMarker(lat, lon);
|
||||
}
|
||||
}, true);
|
||||
|
||||
$scope.$watch('visualization.options.height', function() {
|
||||
if (!marker) return;
|
||||
|
||||
if (!$scope.map) return;
|
||||
$scope.map.invalidateSize(false);
|
||||
setBounds();
|
||||
marker.bindPopup(createDescription(latCol, lonCol, row));
|
||||
markers.addLayer(marker);
|
||||
});
|
||||
|
||||
markers.addTo(map);
|
||||
|
||||
layers[name] = markers;
|
||||
mapControls.addOverlay(markers, name);
|
||||
}
|
||||
|
||||
function render() {
|
||||
var queryData = $scope.queryResult.getData();
|
||||
var classify = $scope.visualization.options.classify;
|
||||
|
||||
$scope.visualization.options.mapTileUrl = $scope.visualization.options.mapTileUrl || '//{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
|
||||
|
||||
tileLayer.setUrl($scope.visualization.options.mapTileUrl);
|
||||
|
||||
if ($scope.visualization.options.clusterMarkers === undefined) {
|
||||
$scope.visualization.options.clusterMarkers = true;
|
||||
}
|
||||
|
||||
if (queryData) {
|
||||
var pointGroups;
|
||||
if (classify && classify != 'none') {
|
||||
pointGroups = _.groupBy(queryData, classify);
|
||||
} else {
|
||||
pointGroups = {'All': queryData};
|
||||
}
|
||||
|
||||
var groupNames = _.keys(pointGroups);
|
||||
var options = _.map(groupNames, function(group) {
|
||||
if ($scope.visualization.options.groups && $scope.visualization.options.groups[group]) {
|
||||
return $scope.visualization.options.groups[group];
|
||||
}
|
||||
return {color: color(group)};
|
||||
});
|
||||
|
||||
$scope.visualization.options.groups = _.object(groupNames, options);
|
||||
|
||||
_.each(layers, function(v, k) {
|
||||
removeLayer(v);
|
||||
});
|
||||
|
||||
_.each(pointGroups, function(v, k) {
|
||||
addLayer(k, v);
|
||||
});
|
||||
|
||||
setBounds();
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -229,8 +234,58 @@
|
||||
restrict: 'E',
|
||||
templateUrl: '/views/visualizations/map_editor.html',
|
||||
link: function($scope, elm, attrs) {
|
||||
$scope.draw_options = ['Marker','Color'];
|
||||
$scope.currentTab = 'general';
|
||||
$scope.classify_columns = $scope.queryResult.columnNames.concat('none');
|
||||
$scope.mapTiles = [
|
||||
{
|
||||
name: 'OpenStreetMap',
|
||||
url: '//{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'
|
||||
},
|
||||
{
|
||||
name: 'OpenStreetMap BW',
|
||||
url: '//{s}.tiles.wmflabs.org/bw-mapnik/{z}/{x}/{y}.png'
|
||||
},
|
||||
{
|
||||
name: 'OpenStreetMap DE',
|
||||
url: '//{s}.tile.openstreetmap.de/tiles/osmde/{z}/{x}/{y}.png'
|
||||
},
|
||||
{
|
||||
name: 'OpenStreetMap FR',
|
||||
url: '//{s}.tile.openstreetmap.fr/osmfr/{z}/{x}/{y}.png'
|
||||
},
|
||||
{
|
||||
name: 'OpenStreetMap Hot',
|
||||
url: '//{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png'
|
||||
},
|
||||
{
|
||||
name: 'Thunderforest',
|
||||
url: '//{s}.tile.thunderforest.com/cycle/{z}/{x}/{y}.png'
|
||||
},
|
||||
{
|
||||
name: 'Thunderforest Spinal',
|
||||
url: '//{s}.tile.thunderforest.com/spinal-map/{z}/{x}/{y}.png'
|
||||
},
|
||||
{
|
||||
name: 'OpenMapSurfer',
|
||||
url: '//korona.geog.uni-heidelberg.de/tiles/roads/x={x}&y={y}&z={z}'
|
||||
},
|
||||
{
|
||||
name: 'Stamen Toner',
|
||||
url: '//stamen-tiles-{s}.a.ssl.fastly.net/toner/{z}/{x}/{y}.png'
|
||||
},
|
||||
{
|
||||
name: 'Stamen Toner Background',
|
||||
url: '//stamen-tiles-{s}.a.ssl.fastly.net/toner-background/{z}/{x}/{y}.png'
|
||||
},
|
||||
{
|
||||
name: 'Stamen Toner Lite',
|
||||
url: '//stamen-tiles-{s}.a.ssl.fastly.net/toner-lite/{z}/{x}/{y}.png'
|
||||
},
|
||||
{
|
||||
name: 'OpenTopoMap',
|
||||
url: '//{s}.tile.opentopomap.org/{z}/{x}/{y}.png'
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
//delete some values which are functions
|
||||
delete configCopy.aggregators;
|
||||
delete configCopy.renderers;
|
||||
delete configCopy.onRefresh;
|
||||
//delete some bulky default values
|
||||
delete configCopy.rendererOptions;
|
||||
delete configCopy.localeStrings;
|
||||
|
||||
268
rd_ui/app/scripts/visualizations/sankey.js
Normal file
268
rd_ui/app/scripts/visualizations/sankey.js
Normal file
@@ -0,0 +1,268 @@
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
var module = angular.module('redash.visualization');
|
||||
|
||||
module.directive('sankeyRenderer', function() {
|
||||
return {
|
||||
restrict: 'E',
|
||||
link: function(scope, element) {
|
||||
var refreshData = function() {
|
||||
var queryData = scope.queryResult.getData();
|
||||
if (queryData) {
|
||||
// do the render logic.
|
||||
angular.element(element[0]).empty();
|
||||
createSankey(element[0], scope.visualization.options.height, queryData);
|
||||
}
|
||||
};
|
||||
|
||||
angular.element(window).on("resize", refreshData);
|
||||
scope.$watch("queryResult && queryResult.getData()", refreshData);
|
||||
scope.$watch('visualization.options.height', function(oldValue, newValue) {
|
||||
if (oldValue !== newValue) {
|
||||
refreshData();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
module.directive('sankeyEditor', function() {
|
||||
return {
|
||||
restrict: 'E',
|
||||
templateUrl: '/views/visualizations/sankey_editor.html'
|
||||
}
|
||||
});
|
||||
|
||||
module.config(['VisualizationProvider', function(VisualizationProvider) {
|
||||
var renderTemplate =
|
||||
'<sankey-renderer options="visualization.options" query-result="queryResult"></sankey-renderer>';
|
||||
|
||||
var editTemplate = '<sankey-editor></sankey-editor>';
|
||||
var defaultOptions = {
|
||||
height: 300
|
||||
};
|
||||
|
||||
VisualizationProvider.registerVisualization({
|
||||
type: 'SANKEY',
|
||||
name: 'Sankey',
|
||||
renderTemplate: renderTemplate,
|
||||
editorTemplate: editTemplate,
|
||||
defaultOptions: defaultOptions
|
||||
});
|
||||
}
|
||||
]);
|
||||
|
||||
function createSankey(element, height, data) {
|
||||
var margin = {top: 10, right: 10, bottom: 10, left: 10},
|
||||
width = $(element).parent().width() - margin.left - margin.right,
|
||||
height = height - margin.top - margin.bottom;
|
||||
|
||||
data = graph(data);
|
||||
|
||||
var formatNumber = d3.format(",.0f"); // zero decimal places
|
||||
var format = function(d) { return formatNumber(d); };
|
||||
var color = d3.scale.category20();
|
||||
|
||||
// append the svg canvas to the page
|
||||
var svg = d3.select(element).append("svg")
|
||||
.attr("class", "sankey")
|
||||
.attr("width", width + margin.left + margin.right)
|
||||
.attr("height", height + margin.top + margin.bottom)
|
||||
.append("g")
|
||||
.attr("transform",
|
||||
"translate(" + margin.left + "," + margin.top + ")");
|
||||
|
||||
// Set the sankey diagram properties
|
||||
var sankey = d3.sankey()
|
||||
.nodeWidth(15)
|
||||
.nodePadding(10)
|
||||
.size([width, height]);
|
||||
|
||||
var path = sankey.link();
|
||||
|
||||
sankey
|
||||
.nodes(data.nodes)
|
||||
.links(data.links)
|
||||
.layout(0);
|
||||
|
||||
spreadNodes(height, data);
|
||||
sankey.relayout();
|
||||
|
||||
// add in the links
|
||||
var link = svg.append("g").selectAll(".link")
|
||||
.data(data.links)
|
||||
.enter().append("path")
|
||||
.filter(function(link) {
|
||||
return link.target.name != 'Exit';
|
||||
})
|
||||
.attr("class", "link")
|
||||
.attr("d", path)
|
||||
.style("stroke-width", function(d) { return Math.max(1, d.dy); })
|
||||
.sort(function(a, b) { return b.dy - a.dy; });
|
||||
|
||||
// add the link titles
|
||||
link.append("title")
|
||||
.text(function(d) {
|
||||
return d.source.name + " → " + d.target.name + "\n" + format(d.value);
|
||||
});
|
||||
|
||||
// add in the nodes
|
||||
var node = svg.append("g").selectAll(".node")
|
||||
.data(data.nodes)
|
||||
.enter().append("g")
|
||||
.filter(function(node) {
|
||||
return node.name != 'Exit';
|
||||
})
|
||||
.attr("class", "node")
|
||||
.attr("transform", function(d) {
|
||||
return "translate(" + d.x + "," + d.y + ")";
|
||||
})
|
||||
.on("mouseover", nodeMouseOver)
|
||||
.on("mouseout", nodeMouseOut);
|
||||
|
||||
// add the rectangles for the nodes
|
||||
node.append("rect")
|
||||
.attr("height", function(d) { return d.dy; })
|
||||
.attr("width", sankey.nodeWidth())
|
||||
.style("fill", function(d) {
|
||||
return d.color = color(d.name.replace(/ .*/, ""));
|
||||
})
|
||||
.style("stroke", function(d) {
|
||||
return d3.rgb(d.color).darker(2);
|
||||
})
|
||||
.append("title").text(function(d) {
|
||||
return d.name + "\n" + format(d.value);
|
||||
});
|
||||
|
||||
// add in the title for the nodes
|
||||
node.append("text")
|
||||
.attr("x", -6)
|
||||
.attr("y", function(d) { return d.dy / 2; })
|
||||
.attr("dy", ".35em")
|
||||
.attr("text-anchor", "end")
|
||||
.attr("transform", null)
|
||||
.text(function(d) { return d.name; })
|
||||
.filter(function(d) { return d.x < width / 2; })
|
||||
.attr("x", 6 + sankey.nodeWidth())
|
||||
.attr("text-anchor", "start");
|
||||
|
||||
function nodeMouseOver(currentNode) {
|
||||
var nodes = getConnectedNodes(currentNode);
|
||||
nodes = _.pluck(nodes, 'id');
|
||||
node.filter(function(d) {
|
||||
if (d === currentNode) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_.contains(nodes, d.id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}).style('opacity', 0.2);
|
||||
link.filter(function(l) {
|
||||
return !(_.include(currentNode.sourceLinks, l) || _.include(currentNode.targetLinks, l));
|
||||
}).style('opacity', 0.2);
|
||||
}
|
||||
|
||||
function nodeMouseOut(currentNode) {
|
||||
node.style('opacity', 1);
|
||||
link.style('opacity', 1);
|
||||
}
|
||||
|
||||
function spreadNodes(height, data) {
|
||||
var nodesByBreadth = d3.nest()
|
||||
.key(function(d) { return d.x; })
|
||||
.entries(data.nodes)
|
||||
.map(function(d) { return d.values; });
|
||||
|
||||
nodesByBreadth.forEach(function(nodes) {
|
||||
nodes = _.filter(_.sortBy(nodes, function(node) { return -node.value; }), function(node) {
|
||||
return node.name !== 'Exit';
|
||||
});
|
||||
|
||||
var sum = d3.sum(nodes, function(o) { return o.dy; });
|
||||
var padding = (height - sum) / nodes.length;
|
||||
|
||||
_.reduce(nodes, function(y0, node) {
|
||||
node.y = y0;
|
||||
return y0 + node.dy + padding;
|
||||
}, 0);
|
||||
});
|
||||
}
|
||||
|
||||
function getConnectedNodes(node) {
|
||||
// source link = this node is the source, I need the targets
|
||||
var nodes = [];
|
||||
_.each(node.sourceLinks, function(link) {
|
||||
nodes.push(link.target);
|
||||
});
|
||||
_.each(node.targetLinks, function(link) {
|
||||
nodes.push(link.source);
|
||||
});
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function graph(data) {
|
||||
var nodesDict = {};
|
||||
var links = {};
|
||||
var nodes = [];
|
||||
|
||||
var keys = _.sortBy(_.without(_.keys(data[0]), 'value'), _.identity);
|
||||
|
||||
data.forEach(function(row) {
|
||||
addLink(row[keys[0]], row[keys[1]], row.value, 1);
|
||||
addLink(row[keys[1]], row[keys[2]], row.value, 2);
|
||||
addLink(row[keys[2]], row[keys[3]], row.value, 3);
|
||||
addLink(row[keys[3]], row[keys[4]], row.value, 4);
|
||||
});
|
||||
|
||||
return {nodes: nodes, links: _.values(links)};
|
||||
|
||||
function normalizeName(name) {
|
||||
if (name) {
|
||||
return name;
|
||||
}
|
||||
|
||||
return 'Exit';
|
||||
}
|
||||
|
||||
function getNode(name, level) {
|
||||
name = normalizeName(name);
|
||||
var key = name + ":" + String(level);
|
||||
var node = nodesDict[key];
|
||||
if (!node) {
|
||||
node = {name: name};
|
||||
var id = nodes.push(node) - 1;
|
||||
node.id = id;
|
||||
nodesDict[key] = node;
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
function getLink(source, target) {
|
||||
var link = links[[source, target]];
|
||||
if (!link) {
|
||||
link = {target: target, source: source, value: 0};
|
||||
links[[source, target]] = link;
|
||||
}
|
||||
|
||||
return link;
|
||||
}
|
||||
|
||||
function addLink(sourceName, targetName, value, depth) {
|
||||
if ((sourceName === '' || !sourceName) && depth > 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
var source = getNode(sourceName, depth);
|
||||
var target = getNode(targetName, depth+1);
|
||||
var link = getLink(source.id, target.id);
|
||||
link.value += parseInt(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
})();
|
||||
495
rd_ui/app/scripts/visualizations/sunburst_sequence.js
Normal file
495
rd_ui/app/scripts/visualizations/sunburst_sequence.js
Normal file
@@ -0,0 +1,495 @@
|
||||
'use strict';
|
||||
|
||||
(function () {
|
||||
var module = angular.module('redash.visualization');
|
||||
|
||||
module.directive('sunburstSequenceRenderer', function () {
|
||||
return {
|
||||
restrict: 'E',
|
||||
link: function(scope, element) {
|
||||
var sunburst = new Sunburst(scope, element);
|
||||
|
||||
function resize() {
|
||||
sunburst.remove();
|
||||
sunburst = new Sunburst(scope, element);
|
||||
}
|
||||
|
||||
angular.element(window).on("resize", resize);
|
||||
scope.$watch('visualization.options.height', function(oldValue, newValue) {
|
||||
if (oldValue !== newValue) {
|
||||
resize();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
module.directive('sunburstSequenceEditor', function () {
|
||||
return {
|
||||
restrict: 'E',
|
||||
templateUrl: '/views/visualizations/sunburst_sequence_editor.html'
|
||||
}
|
||||
});
|
||||
|
||||
module.config(['VisualizationProvider', function (VisualizationProvider) {
|
||||
var renderTemplate =
|
||||
'<sunburst-sequence-renderer options="visualization.options" query-result="queryResult"></sunburst-sequence-renderer>';
|
||||
|
||||
var editTemplate = '<sunburst-sequence-editor></sunburst-sequence-editor>';
|
||||
var defaultOptions = {
|
||||
height: 300,
|
||||
//
|
||||
};
|
||||
|
||||
VisualizationProvider.registerVisualization({
|
||||
type: 'SUNBURST_SEQUENCE',
|
||||
name: 'Sunburst Sequence',
|
||||
renderTemplate: renderTemplate,
|
||||
editorTemplate: editTemplate,
|
||||
defaultOptions: defaultOptions
|
||||
});
|
||||
}
|
||||
]);
|
||||
|
||||
|
||||
// The following is based on @chrisrzhou's example from: http://bl.ocks.org/chrisrzhou/d5bdd8546f64ca0e4366.
|
||||
function Sunburst(scope, element) {
|
||||
this.element = element;
|
||||
|
||||
var refreshData = function () {
|
||||
var queryData = scope.queryResult.getData();
|
||||
if (queryData) {
|
||||
render(queryData);
|
||||
}
|
||||
};
|
||||
|
||||
this.watches = [];
|
||||
this.watches.push(scope.$watch("visualization.options", refreshData, true));
|
||||
this.watches.push(scope.$watch("queryResult && queryResult.getData()", refreshData));
|
||||
|
||||
var exitNode = "<<<Exit>>>";
|
||||
// svg dimensions
|
||||
var width = element[0].parentElement.clientWidth;
|
||||
var height = scope.visualization.options.height;
|
||||
var radius = Math.min(width, height) / 2;
|
||||
|
||||
// Breadcrumb dimensions: width, height, spacing, width of tip/tail.
|
||||
var b = {
|
||||
w: width / 6,
|
||||
h: 30,
|
||||
s: 3,
|
||||
t: 10
|
||||
};
|
||||
|
||||
// Legend dimensions: width, height, spacing, radius of rounded rect.
|
||||
var li = {
|
||||
w: 75,
|
||||
h: 30,
|
||||
s: 3,
|
||||
r: 3
|
||||
};
|
||||
|
||||
// margins
|
||||
var margin = {
|
||||
top: radius,
|
||||
bottom: 50,
|
||||
left: radius,
|
||||
right: 0
|
||||
};
|
||||
|
||||
/**
|
||||
* Drawing variables:
|
||||
*
|
||||
* e.g. colors, totalSize, partitions, arcs
|
||||
*/
|
||||
// Mapping of nodes to colorscale.
|
||||
var colors = d3.scale.category10();
|
||||
|
||||
// Total size of all nodes, to be used later when data is loaded
|
||||
var totalSize = 0;
|
||||
|
||||
// create d3.layout.partition
|
||||
var partition = d3.layout.partition()
|
||||
.size([2 * Math.PI, radius * radius])
|
||||
.value(function (d) {
|
||||
return d.size;
|
||||
});
|
||||
|
||||
// create arcs for drawing D3 paths
|
||||
var arc = d3.svg.arc()
|
||||
.startAngle(function (d) {
|
||||
return d.x;
|
||||
})
|
||||
.endAngle(function (d) {
|
||||
return d.x + d.dx;
|
||||
})
|
||||
.innerRadius(function (d) {
|
||||
return Math.sqrt(d.y);
|
||||
})
|
||||
.outerRadius(function (d) {
|
||||
return Math.sqrt(d.y + d.dy);
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* Define and initialize D3 select references and div-containers
|
||||
*
|
||||
* e.g. vis, breadcrumbs, lastCrumb, summary, sunburst, legend
|
||||
*/
|
||||
// create main vis selection
|
||||
var vis = d3.select(element[0])
|
||||
.append("div").classed("vis-container", true)
|
||||
.style("position", "relative")
|
||||
.style("margin-top", "5px")
|
||||
.style("height", height + 2 * b.h + "px");
|
||||
|
||||
// create and position breadcrumbs container and svg
|
||||
var breadcrumbs = vis
|
||||
.append("div").classed("breadcrumbs-container", true)
|
||||
.append("svg")
|
||||
.attr("width", width)
|
||||
.attr("height", b.h)
|
||||
.attr("fill", "white")
|
||||
.attr("font-weight", 600);
|
||||
|
||||
var marginLeft = (width - radius * 2) / 2;
|
||||
|
||||
// create and position SVG
|
||||
var sunburst = vis
|
||||
.append("div").classed("sunburst-container", true)
|
||||
.style('z-index', '2')
|
||||
// .style("margin-left", marginLeft + "px")
|
||||
.style("left", marginLeft + "px")
|
||||
.style('position', 'absolute')
|
||||
.append("svg")
|
||||
.attr("width", width)
|
||||
.attr("height", height)
|
||||
.append("g")
|
||||
.attr("transform", "translate(" + margin.left + "," + margin.top + ")");
|
||||
|
||||
// create last breadcrumb element
|
||||
var lastCrumb = breadcrumbs.append("text").classed("lastCrumb", true);
|
||||
|
||||
// create and position summary container
|
||||
var summary = vis
|
||||
.append("div").classed("summary-container", true)
|
||||
.style("position", "absolute")
|
||||
.style("top", b.h + radius * 0.80 + "px")
|
||||
.style("left", marginLeft + radius / 2 + "px")
|
||||
.style("width", radius + "px")
|
||||
.style("height", radius + "px")
|
||||
.style("text-align", "center")
|
||||
.style("font-size", "11px")
|
||||
.style("color", "#666")
|
||||
.style('z-index', '1');
|
||||
|
||||
refreshData();
|
||||
|
||||
/**
|
||||
* Render process:
|
||||
*
|
||||
* 1) Load data
|
||||
* 2) Build Tree
|
||||
* 3) Draw visualization
|
||||
*/
|
||||
// render visualization
|
||||
function render(data) {
|
||||
var json = buildHierarchy(data); // build json tree
|
||||
removeVisualization(); // remove existing visualization if any
|
||||
createVisualization(json); // visualize json tree
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Helper functions:
|
||||
*
|
||||
* @function removeVisualization(): removes existing SVG components
|
||||
* @function createVisualization(json): create visualization from json tree structure
|
||||
* @function colorMap(d): color nodes with colors mapping
|
||||
* @function mouseover(d): mouseover function
|
||||
* @function mouseleave(d): mouseleave function
|
||||
* @function getAncestors(node): get ancestors of a specified node
|
||||
* @function buildHierarchy(data): generate json nested structure from csv data input
|
||||
*/
|
||||
// removes existing SVG components
|
||||
function removeVisualization() {
|
||||
sunburst.selectAll(".nodePath").remove();
|
||||
// legend.selectAll("g").remove();
|
||||
}
|
||||
|
||||
|
||||
// visualize json tree structure
|
||||
function createVisualization(json) {
|
||||
drawSunburst(json); // draw sunburst
|
||||
// drawLegend(); // draw legend
|
||||
};
|
||||
|
||||
// helper function colorMap - color gray if "end" is detected
|
||||
function colorMap(d) {
|
||||
return colors(d.name);
|
||||
}
|
||||
|
||||
|
||||
// helper function to draw the sunburst and breadcrumbs
|
||||
function drawSunburst(json) {
|
||||
// Build only nodes of a threshold "visible" sizes to improve efficiency
|
||||
var nodes = partition.nodes(json)
|
||||
.filter(function (d) {
|
||||
return (d.dx > 0.005) && d.name !== exitNode; // 0.005 radians = 0.29 degrees
|
||||
});
|
||||
|
||||
// this section is required to update the colors.domain() every time the data updates
|
||||
var uniqueNames = (function (a) {
|
||||
var output = [];
|
||||
a.forEach(function (d) {
|
||||
if (output.indexOf(d.name) === -1) output.push(d.name);
|
||||
});
|
||||
return output;
|
||||
})(nodes);
|
||||
colors.domain(uniqueNames); // update domain colors
|
||||
|
||||
// create path based on nodes
|
||||
var path = sunburst.data([json]).selectAll("path")
|
||||
.data(nodes).enter()
|
||||
.append("path").classed("nodePath", true)
|
||||
.attr("display", function (d) {
|
||||
return d.depth ? null : "none";
|
||||
})
|
||||
.attr("d", arc)
|
||||
.attr("fill", colorMap)
|
||||
.attr("opacity", 1)
|
||||
.attr("stroke", "white")
|
||||
.on("mouseover", mouseover);
|
||||
|
||||
|
||||
// // trigger mouse click over sunburst to reset visualization summary
|
||||
vis.on("click", click);
|
||||
|
||||
// Update totalSize of the tree = value of root node from partition.
|
||||
totalSize = path.node().__data__.value;
|
||||
}
|
||||
|
||||
// helper function mouseover to handle mouseover events/animations and calculation of ancestor nodes etc
|
||||
function mouseover(d) {
|
||||
// build percentage string
|
||||
var percentage = (100 * d.value / totalSize).toPrecision(3);
|
||||
var percentageString = percentage + "%";
|
||||
if (percentage < 1) {
|
||||
percentageString = "< 1.0%";
|
||||
}
|
||||
|
||||
// update breadcrumbs (get all ancestors)
|
||||
var ancestors = getAncestors(d);
|
||||
updateBreadcrumbs(ancestors, percentageString);
|
||||
|
||||
// update sunburst (Fade all the segments and highlight only ancestors of current segment)
|
||||
sunburst.selectAll("path")
|
||||
.attr("opacity", 0.3);
|
||||
sunburst.selectAll("path")
|
||||
.filter(function (node) {
|
||||
return (ancestors.indexOf(node) >= 0);
|
||||
})
|
||||
.attr("opacity", 1);
|
||||
|
||||
// update summary
|
||||
summary.html(
|
||||
"Stage: " + d.depth + "<br />" +
|
||||
"<span class='percentage' style='font-size: 2em;'>" + percentageString + "</span><br />" +
|
||||
d.value + " of " + totalSize + "<br />"
|
||||
);
|
||||
|
||||
// display summary and breadcrumbs if hidden
|
||||
summary.style("visibility", "");
|
||||
breadcrumbs.style("visibility", "");
|
||||
}
|
||||
|
||||
|
||||
// helper function click to handle mouseleave events/animations
|
||||
function click(d) {
|
||||
// Deactivate all segments then retransition each segment to full opacity.
|
||||
sunburst.selectAll("path").on("mouseover", null);
|
||||
sunburst.selectAll("path")
|
||||
.transition()
|
||||
.duration(1000)
|
||||
.attr("opacity", 1)
|
||||
.each("end", function () {
|
||||
d3.select(this).on("mouseover", mouseover);
|
||||
});
|
||||
|
||||
// hide summary and breadcrumbs if visible
|
||||
breadcrumbs.style("visibility", "hidden");
|
||||
summary.style("visibility", "hidden");
|
||||
}
|
||||
|
||||
|
||||
// Return array of ancestors of nodes, highest first, but excluding the root.
|
||||
function getAncestors(node) {
|
||||
var path = [];
|
||||
var current = node;
|
||||
|
||||
while (current.parent) {
|
||||
path.unshift(current);
|
||||
current = current.parent;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
|
||||
// Generate a string representation for drawing a breadcrumb polygon.
|
||||
function breadcrumbPoints(d, i) {
|
||||
var points = [];
|
||||
points.push("0,0");
|
||||
points.push(b.w + ",0");
|
||||
points.push(b.w + b.t + "," + (b.h / 2));
|
||||
points.push(b.w + "," + b.h);
|
||||
points.push("0," + b.h);
|
||||
|
||||
if (i > 0) { // Leftmost breadcrumb; don't include 6th vertex.
|
||||
points.push(b.t + "," + (b.h / 2));
|
||||
}
|
||||
return points.join(" ");
|
||||
}
|
||||
|
||||
|
||||
// Update the breadcrumb breadcrumbs to show the current sequence and percentage.
|
||||
function updateBreadcrumbs(ancestors, percentageString) {
|
||||
// Data join, where primary key = name + depth.
|
||||
var g = breadcrumbs.selectAll("g")
|
||||
.data(ancestors, function (d) {
|
||||
return d.name + d.depth;
|
||||
});
|
||||
|
||||
// Add breadcrumb and label for entering nodes.
|
||||
var breadcrumb = g.enter().append("g");
|
||||
|
||||
breadcrumb
|
||||
.append("polygon").classed("breadcrumbs-shape", true)
|
||||
.attr("points", breadcrumbPoints)
|
||||
.attr("fill", colorMap);
|
||||
|
||||
breadcrumb
|
||||
.append("text").classed("breadcrumbs-text", true)
|
||||
.attr("x", (b.w + b.t) / 2)
|
||||
.attr("y", b.h / 2)
|
||||
.attr("dy", "0.35em")
|
||||
.attr("font-size", "10px")
|
||||
.attr("text-anchor", "middle")
|
||||
.text(function (d) {
|
||||
return d.name;
|
||||
});
|
||||
|
||||
// Set position for entering and updating nodes.
|
||||
g.attr("transform", function (d, i) {
|
||||
return "translate(" + i * (b.w + b.s) + ", 0)";
|
||||
});
|
||||
|
||||
// Remove exiting nodes.
|
||||
g.exit().remove();
|
||||
|
||||
// Update percentage at the lastCrumb.
|
||||
lastCrumb
|
||||
.attr("x", (ancestors.length + 0.5) * (b.w + b.s))
|
||||
.attr("y", b.h / 2)
|
||||
.attr("dy", "0.35em")
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("fill", "black")
|
||||
.attr("font-weight", 600)
|
||||
.text(percentageString);
|
||||
}
|
||||
|
||||
function buildHierarchy(csv) {
|
||||
var data = buildNodes(csv);
|
||||
|
||||
// build tree
|
||||
var root = {
|
||||
name: "root",
|
||||
children: []
|
||||
};
|
||||
|
||||
data.forEach(function (d) {
|
||||
var nodes = d.nodes;
|
||||
var size = parseInt(d.size);
|
||||
|
||||
// build graph, nodes, and child nodes
|
||||
var currentNode = root;
|
||||
for (var j = 0; j < nodes.length; j++) {
|
||||
var children = currentNode.children;
|
||||
var nodeName = nodes[j];
|
||||
var isLeaf = j + 1 === nodes.length;
|
||||
|
||||
|
||||
if (!children) {
|
||||
currentNode.children = children = [];
|
||||
children.push({
|
||||
name: exitNode,
|
||||
size: currentNode.size
|
||||
})
|
||||
}
|
||||
|
||||
var childNode = _.find(children, function(child) { return child.name == nodeName });
|
||||
|
||||
if (isLeaf && childNode) {
|
||||
childNode.children.push({
|
||||
name: exitNode,
|
||||
size: size
|
||||
})
|
||||
} else if (isLeaf) {
|
||||
children.push({
|
||||
name: nodeName,
|
||||
size: size
|
||||
})
|
||||
} else {
|
||||
if (!childNode) {
|
||||
childNode = {
|
||||
name: nodeName,
|
||||
children: []
|
||||
};
|
||||
children.push(childNode);
|
||||
}
|
||||
|
||||
currentNode = childNode;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
function buildNodes(raw) {
|
||||
var values;
|
||||
|
||||
if (_.has(raw[0], 'sequence') && _.has(raw[0], 'stage') && _.has(raw[0], 'node') && _.has(raw[0], 'value')) {
|
||||
|
||||
var grouped = _.groupBy(raw, 'sequence');
|
||||
|
||||
var values = _.map(grouped, function(value, key) {
|
||||
var sorted = _.sortBy(value, 'stage');
|
||||
return {
|
||||
size: value[0].value,
|
||||
sequence: value[0].sequence,
|
||||
nodes: _.pluck(sorted, 'node')
|
||||
}
|
||||
});
|
||||
} else {
|
||||
var keys = _.sortBy(_.without(_.keys(raw[0]), 'value'), _.identity);
|
||||
|
||||
values = _.map(raw, function(row, sequence) {
|
||||
return {
|
||||
size: row.value,
|
||||
sequence: sequence,
|
||||
nodes: _.compact(_.map(keys, function(key) { return row[key] }))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
}
|
||||
|
||||
Sunburst.prototype.remove = function() {
|
||||
_.each(this.watches, function(unregister) { unregister() });
|
||||
angular.element(this.element[0]).empty('.vis-container');
|
||||
}
|
||||
|
||||
|
||||
})();
|
||||
97
rd_ui/app/scripts/visualizations/wordcloud.js
Normal file
97
rd_ui/app/scripts/visualizations/wordcloud.js
Normal file
@@ -0,0 +1,97 @@
|
||||
(function () {
|
||||
var wordCloudVisualization = angular.module('redash.visualization');
|
||||
|
||||
wordCloudVisualization.config(['VisualizationProvider', function (VisualizationProvider) {
|
||||
VisualizationProvider.registerVisualization({
|
||||
type: 'WORD_CLOUD',
|
||||
name: 'Word Cloud',
|
||||
renderTemplate: '<word-cloud-renderer options="visualization.options" query-result="queryResult"></word-cloud-renderer>',
|
||||
editorTemplate: '<word-cloud-editor></word-cloud-editor>'
|
||||
});
|
||||
}]);
|
||||
|
||||
wordCloudVisualization.directive('wordCloudRenderer', function () {
|
||||
return {
|
||||
restrict: 'E',
|
||||
link: function($scope, elem, attrs) {
|
||||
|
||||
reloadCloud = function () {
|
||||
|
||||
if (!angular.isDefined($scope.queryResult)) retun;
|
||||
data = $scope.queryResult.getData();
|
||||
cloud = d3.cloud;
|
||||
|
||||
wordsHash = {};
|
||||
if($scope.visualization.options.column){
|
||||
data.map(function(d) {
|
||||
d[$scope.visualization.options.column]
|
||||
.toString()
|
||||
.split(' ')
|
||||
.map(function(d) {
|
||||
if (d in wordsHash) {
|
||||
wordsHash[d]+=1;
|
||||
} else {
|
||||
wordsHash[d]=1;
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
wordList = [];
|
||||
for(var key in wordsHash) {
|
||||
wordList.push({text: key, size: 10 + Math.pow(wordsHash[key],2)});
|
||||
}
|
||||
|
||||
var fill = d3.scale.category20();
|
||||
|
||||
var layout = cloud()
|
||||
.size([500, 500])
|
||||
.words(wordList)
|
||||
.padding(5)
|
||||
.rotate(function() { return ~~(Math.random() * 2) * 90; })
|
||||
.font("Impact")
|
||||
.fontSize(function(d) { return d.size; })
|
||||
.on("end", draw);
|
||||
|
||||
layout.start();
|
||||
|
||||
function draw(words) {
|
||||
d3.select(elem[0].parentNode)
|
||||
.select("svg")
|
||||
.remove();
|
||||
|
||||
d3.select(elem[0].parentNode)
|
||||
.append("svg")
|
||||
.attr("width", layout.size()[0])
|
||||
.attr("height", layout.size()[1])
|
||||
.append("g")
|
||||
.attr("transform", "translate(" + layout.size()[0] / 2 + "," + layout.size()[1] / 2 + ")")
|
||||
.selectAll("text")
|
||||
.data(words)
|
||||
.enter().append("text")
|
||||
.style("font-size", function(d) { return d.size + "px"; })
|
||||
.style("font-family", "Impact")
|
||||
.style("fill", function(d, i) { return fill(i); })
|
||||
.attr("text-anchor", "middle")
|
||||
.attr("transform", function(d) {
|
||||
return "translate(" + [d.x, d.y] + ")rotate(" + d.rotate + ")";
|
||||
})
|
||||
.text(function(d) { return d.text; });
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
$scope.$watch('queryResult && queryResult.getData()', reloadCloud);
|
||||
$scope.$watch('visualization.options.column', reloadCloud);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
wordCloudVisualization.directive('wordCloudEditor', function() {
|
||||
return {
|
||||
restrict: 'E',
|
||||
templateUrl: '/views/visualizations/word_cloud_editor.html'
|
||||
};
|
||||
});
|
||||
|
||||
})();
|
||||
@@ -40,6 +40,8 @@
|
||||
<!-- build:js /scripts/layout_vendor.js -->
|
||||
<script src="/bower_components/jquery/jquery.js"></script>
|
||||
<!-- endbuild -->
|
||||
{% block scripts %}
|
||||
{% endblock %}
|
||||
|
||||
{% include '_includes/signed_out_tail.html' %}
|
||||
|
||||
|
||||
@@ -158,23 +158,6 @@ a.navbar-brand img {
|
||||
|
||||
}
|
||||
|
||||
/* Visualization Filters */
|
||||
|
||||
.filters-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter {
|
||||
width: 33%;
|
||||
padding-left: 5px;
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
|
||||
.filter > div {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Gridster */
|
||||
|
||||
.gridster ul {
|
||||
@@ -205,18 +188,14 @@ li.widget:hover {
|
||||
background: rgba(0, 0, 0, 0.5) !important;
|
||||
}
|
||||
|
||||
/* CodeMirror */
|
||||
.CodeMirror {
|
||||
/* Editor */
|
||||
|
||||
.ace_editor {
|
||||
border: 1px solid #eee;
|
||||
height: 100%;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.CodeMirror-scroll {
|
||||
overflow-y: hidden;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* Support for Font-Awesome in btn-xs */
|
||||
|
||||
.btn-xs > .fa {
|
||||
@@ -509,6 +488,10 @@ div.table-name:hover {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.smart-table .smart-table-header-row .header-content {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.voffset {
|
||||
margin-top: 2px;
|
||||
}
|
||||
@@ -657,3 +640,42 @@ div.table-name:hover {
|
||||
.t-body a.actions.open > a {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* ui-select adjustments for SuperFlat */
|
||||
|
||||
/* Same definition as .form-control */
|
||||
.ui-select-toggle.btn-default {
|
||||
height: 35px;
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
line-height: 1.42857143;
|
||||
color: #9E9E9E;
|
||||
background: #fff none;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 5px;
|
||||
-webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
|
||||
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075);
|
||||
-webkit-transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s;
|
||||
-o-transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s;
|
||||
transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s;
|
||||
}
|
||||
|
||||
.t-header.widget {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
/* Sankey Visualization */
|
||||
.sankey .node rect {
|
||||
fill-opacity: .9;
|
||||
shape-rendering: crispEdges;
|
||||
stroke-width: 0;
|
||||
}
|
||||
.sankey .node text {
|
||||
text-shadow: 0 1px 0 #fff;
|
||||
}
|
||||
.sankey .link {
|
||||
fill: none;
|
||||
stroke: #000;
|
||||
stroke-opacity: .2;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<!-- build:js /scripts/plugins.js -->
|
||||
<script src="/bower_components/jquery/jquery.js"></script>
|
||||
<script src="/bower_components/ace-builds/src-min-noconflict/ace.js"></script>
|
||||
<script src="/bower_components/ace-builds/src-min-noconflict/mode-sql.js"></script>
|
||||
<script src="/bower_components/ace-builds/src-min-noconflict/mode-json.js"></script>
|
||||
<script src="/bower_components/ace-builds/src-min-noconflict/mode-python.js"></script>
|
||||
<script src="/bower_components/ace-builds/src-min-noconflict/mode-snippets.js"></script>
|
||||
<script src="/bower_components/ace-builds/src-min-noconflict/ext-language_tools.js"></script>
|
||||
<script src="/bower_components/angular/angular.js"></script>
|
||||
<script src="/bower_components/angular-sanitize/angular-sanitize.js"></script>
|
||||
<script src="/bower_components/angular-resizable/src/angular-resizable.js"></script>
|
||||
@@ -11,14 +17,6 @@
|
||||
<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>
|
||||
@@ -29,7 +27,7 @@
|
||||
<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.js/dist/plotly.js"></script>
|
||||
<script src="/bower_components/plotly.js/dist/plotly-basic.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>
|
||||
@@ -42,4 +40,7 @@
|
||||
<script src="/bower_components/angular-bootstrap-show-errors/src/showErrors.js"></script>
|
||||
<script src="/bower_components/d3/d3.js"></script>
|
||||
<script src="/bower_components/angular-ui-sortable/sortable.js"></script>
|
||||
<script src="/bower_components/angular-ui-ace/ui-ace.js"></script>
|
||||
<script src="/bower_components/angular-vs-repeat/src/angular-vs-repeat.js"></script>
|
||||
<script src="/bower_components/leaflet.markercluster/dist/leaflet.markercluster-src.js"></script>
|
||||
<!-- endbuild -->
|
||||
|
||||
@@ -15,5 +15,6 @@
|
||||
<div class="badge">
|
||||
Last update: <span am-time-ago="updatedAt"></span>
|
||||
</div>
|
||||
(<label><input type="checkbox" ng-model="autoUpdate"> Auto Update</label>)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,12 +12,14 @@
|
||||
<ul class="tab-nav">
|
||||
<rd-tab tab-id="in_progress" name="In Progress ({{tasks.in_progress.length}})" ng-click="setTab('in_progress')"></rd-tab>
|
||||
<rd-tab tab-id="waiting" name="Waiting ({{tasks.waiting.length}})" ng-click="setTab('waiting')"></rd-tab>
|
||||
<rd-tab tab-id="done" name="Done ({{tasks.done.length}})" ng-click="setTab('done')"></rd-tab>
|
||||
<rd-tab tab-id="done" name="Done" ng-click="setTab('done')"></rd-tab>
|
||||
</ul>
|
||||
|
||||
<smart-table rows="showingTasks" columns="gridColumns"
|
||||
config="gridConfig"
|
||||
class="table table-condensed table-hover"></smart-table>
|
||||
|
||||
<label><input type="checkbox" ng-model="autoUpdate"> Auto Update</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
<li class="list-group-item active">Queues</li>
|
||||
<li class="list-group-item" ng-repeat="(name, value) in manager.queues">
|
||||
<span class="badge">{{value.size}}</span>
|
||||
{{name}} ({{value.data_sources}})
|
||||
{{name}} <span popover="{{value.data_sources}}" popover-trigger="mouseenter"><i class="fa fa-question-circle"></i></span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
27
rd_ui/app/views/alerts/alert_subscriptions.html
Normal file
27
rd_ui/app/views/alerts/alert_subscriptions.html
Normal file
@@ -0,0 +1,27 @@
|
||||
<div class="p-5">
|
||||
<h4>Notifications</h4>
|
||||
|
||||
<div>
|
||||
<ui-select ng-model="newSubscription.destination" ng-disabled="destinations.length == 0">
|
||||
<ui-select-match><span ng-bind-html="destinationsDisplay($select.selected)"></span></ui-select-match>
|
||||
<ui-select-choices repeat="d in destinations">
|
||||
<span ng-bind-html="destinationsDisplay(d)"></span>
|
||||
</ui-select-choices>
|
||||
</ui-select>
|
||||
</div>
|
||||
<div class="m-t-5">
|
||||
<button class="btn btn-default" ng-click="saveSubscriber()" ng-disabled="destinations.length == 0" style="width:50%;">Add</button>
|
||||
<span class="pull-right m-t-5">
|
||||
<a href="destinations/new" ng-if="currentUser.isAdmin">Create New Destination</a>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<hr/>
|
||||
|
||||
<div>
|
||||
<div class="list-group-item" ng-repeat="subscriber in subscribers">
|
||||
<span ng-bind-html="destinationsDisplay(subscriber)"></span>
|
||||
<button class="btn btn-xs btn-danger pull-right" ng-click="unsubscribe(subscriber)" ng-if="currentUser.isAdmin || currentUser.id == subscriber.user.id">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -7,10 +7,10 @@
|
||||
<div class="container">
|
||||
<div class="row bg-white p-10">
|
||||
<div class="col-md-8">
|
||||
<form name="alertForm" ng-submit="saveChanges()" class="form">
|
||||
<form name="alertForm" class="form">
|
||||
<div class="form-group">
|
||||
<label>Query</label>
|
||||
<ui-select ng-model="alert.query" reset-search-input="false" on-select="onQuerySelected($item)">
|
||||
<ui-select ng-model="alert.query" reset-search-input="false" on-select="onQuerySelected($item)" ng-disabled="!canEdit">
|
||||
<ui-select-match placeholder="Search a query by name">{{$select.selected.name}}</ui-select-match>
|
||||
<ui-select-choices repeat="q in queries"
|
||||
refresh="searchQueries($select.search)"
|
||||
@@ -22,7 +22,7 @@
|
||||
|
||||
<div class="form-group" ng-show="selectedQuery">
|
||||
<label>Name</label>
|
||||
<input type="string" placeholder="{{getDefaultName()}}" class="form-control" ng-model="alert.name">
|
||||
<input type="string" placeholder="{{getDefaultName()}}" class="form-control" ng-model="alert.name" ng-disabled="!canEdit">
|
||||
</div>
|
||||
|
||||
<div ng-show="queryResult" class="form-horizontal">
|
||||
@@ -30,7 +30,7 @@
|
||||
<label class="control-label col-md-2">Value column</label>
|
||||
<div class="col-md-4">
|
||||
<select ng-options="name for name in queryResult.getColumnNames()" ng-model="alert.options.column"
|
||||
class="form-control"></select>
|
||||
class="form-control" ng-disabled="!canEdit"></select>
|
||||
</div>
|
||||
<label class="control-label col-md-2">Value</label>
|
||||
<div class="col-md-4">
|
||||
@@ -40,29 +40,30 @@
|
||||
<div class="form-group">
|
||||
<label class="control-label col-md-2">Op</label>
|
||||
<div class="col-md-4">
|
||||
<select ng-options="name for name in ops" ng-model="alert.options.op" class="form-control"></select>
|
||||
<select ng-options="name for name in ops" ng-model="alert.options.op" class="form-control" ng-disabled="!canEdit"></select>
|
||||
</div>
|
||||
<label class="control-label col-md-2">Reference</label>
|
||||
<div class="col-md-4">
|
||||
<input type="number" class="form-control" ng-model="alert.options.value" placeholder="reference value"
|
||||
<input type="number" step="any" class="form-control" ng-model="alert.options.value" placeholder="reference value" ng-disabled="!canEdit"
|
||||
required/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label col-md-2">Rearm seconds</label>
|
||||
<div class="col-md-4">
|
||||
<input type="number" class="form-control" ng-model="alert.rearm"/>
|
||||
<input type="number" class="form-control" ng-model="alert.rearm" ng-disabled="!canEdit"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<button class="btn btn-primary" ng-disabled="!alertForm.$valid">Save</button>
|
||||
<div class="form-group" ng-if="canEdit">
|
||||
<button class="btn btn-primary" ng-disabled="!alertForm.$valid" ng-click="saveChanges()">Save</button>
|
||||
<button class="btn btn-danger" ng-if="alert.id" ng-click="delete()">Delete</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="col-md-4" ng-if="alert.id">
|
||||
<alert-subscribers alert-id="alert.id"></alert-subscribers>
|
||||
<alert-subscriptions alert-id="alert.id"></alert-subscriptions>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
<div>
|
||||
<strong>Subscribers</strong> <subscribe-button alert-id="alertId" subscribers="subscribers"></subscribe-button><br/>
|
||||
<img ng-src="{{s.user.gravatar_url}}" class="img-circle" alt="{{s.user.name}}" ng-repeat="s in subscribers"/>
|
||||
</div>
|
||||
@@ -60,7 +60,7 @@
|
||||
<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>
|
||||
<a href="users" title="Settings"><i class="fa fa-cog"></i></a>
|
||||
</li>
|
||||
<li class="dropdown" dropdown>
|
||||
<a href="#" class="dropdown-toggle" dropdown-toggle><span ng-bind="currentUser.name"></span> <span
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<page-header title="{{dashboard.name}}">
|
||||
<span ng-if="!dashboard.is_archived && !public" class="hidden-print">
|
||||
<button type="button" class="btn btn-sm" ng-class="{'btn-default': !refreshEnabled, 'btn-primary': refreshEnabled}" tooltip="Enable/Disable Auto Refresh" ng-click="triggerRefresh()">
|
||||
<span class="zmdi zmdi-refresh-sync"></span>
|
||||
<span class="zmdi zmdi-refresh"></span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm" ng-class="{'btn-default': !isFullscreen, 'btn-primary': isFullscreen}" tooltip="Enable/Disable Fullscreen display" ng-click="toggleFullscreen()">
|
||||
<span class="zmdi zmdi-fullscreen"></span>
|
||||
@@ -21,6 +21,7 @@
|
||||
<ul class="dropdown-menu pull-right" dropdown-menu>
|
||||
<li><a data-toggle="modal" hash-link hash="edit_dashboard_dialog">Edit Dashboard</a></li>
|
||||
<li><a data-toggle="modal" hash-link hash="add_query_dialog">Add Widget</a></li>
|
||||
<li ng-if="showPermissionsControl"><a ng-click="showManagePermissionsModal()">Manage Permissions</a></li>
|
||||
<li ng-if="!dashboard.is_archived"><a ng-click="archiveDashboard()">Archive Dashboard</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -29,11 +30,14 @@
|
||||
This dashboard is archived and won't appear in the dashboards list or search results.
|
||||
</div>
|
||||
|
||||
<div class="m-b-5">
|
||||
<filters ng-if="dashboard.dashboard_filters_enabled"></filters>
|
||||
</div>
|
||||
|
||||
<div ng-repeat="row in dashboard.widgets" class="row">
|
||||
<div ng-repeat="widget in row" class="col-lg-{{widget.width | colWidth}}" ng-controller='WidgetCtrl'>
|
||||
<div class="tile" ng-if="type=='visualization'">
|
||||
<div class="t-header">
|
||||
<div class="t-header widget">
|
||||
<div class="th-title">
|
||||
<p class="hidden-print">
|
||||
<span ng-hide="currentUser.hasPermission('view_query')">{{query.name}}</span>
|
||||
@@ -53,18 +57,31 @@
|
||||
<li><a ng-disabled="!queryResult.getData()" query-result-link target="_self">Download as CSV File</a></li>
|
||||
<li><a ng-disabled="!queryResult.getData()" file-type="xlsx" query-result-link target="_self" >Download as Excel File</a></li>
|
||||
<li><a ng-href="queries/{{query.id}}#{{widget.visualization.id}}" ng-show="currentUser.hasPermission('view_query')">View Query</a></li>
|
||||
<li><a ng-show="dashboard.canEdit()" ng-click="deleteWidget()">Remove From Dashbaord</a></li>
|
||||
<li><a ng-show="dashboard.canEdit()" ng-click="deleteWidget()">Remove From Dashboard</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<visualization-renderer visualization="widget.visualization" query-result="queryResult" class="t-body"></visualization-renderer>
|
||||
<parameters parameters="widget.query.getParametersDefs()"></parameters>
|
||||
|
||||
<div class="panel-footer">
|
||||
<span class="label label-default hidden-print">Updated: <span am-time-ago="queryResult.getUpdatedAt()"></span></span>
|
||||
<div ng-switch="queryResult.getStatus()">
|
||||
<div ng-switch-when="failed">
|
||||
<div class="alert alert-danger m-5" ng-show="queryResult.getError()">Error running query: <strong>{{queryResult.getError()}}</strong></div>
|
||||
</div>
|
||||
<div ng-switch-when="done">
|
||||
<visualization-renderer visualization="widget.visualization" query-result="queryResult" class="t-body"></visualization-renderer>
|
||||
</div>
|
||||
<div ng-switch-default class="text-center">
|
||||
<i class="zmdi zmdi-refresh zmdi-hc-spin zmdi-hc-5x"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-5 clearfix" style="line-height:28px;">
|
||||
<span class="small hidden-print">Updated: <span am-time-ago="queryResult.getUpdatedAt()"></span></span>
|
||||
<span class="visible-print">
|
||||
Updated: {{queryResult.getUpdatedAt() | dateTime}}
|
||||
</span>
|
||||
<button class="btn btn-sm btn-default pull-right hidden-print" ng-click="reload(true)" ng-if="!public"><i class="zmdi zmdi-refresh"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -87,7 +104,8 @@
|
||||
</div>
|
||||
|
||||
<ul class="dropdown-menu pull-right" dropdown-menu style="z-index:1000000">
|
||||
<li><a ng-show="dashboard.canEdit()" ng-click="deleteWidget()">Remove From Dashbaord</a></li>
|
||||
<li><a ng-show="dashboard.canEdit()" ng-click="editTextBox()">Edit</a></li>
|
||||
<li><a ng-show="dashboard.canEdit()" ng-click="deleteWidget()">Remove From Dashboard</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<p ng-bind-html="widget.text | markdown" class="p-5"></p>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<settings-screen>
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<data-source-form data-data-source="dataSource" />
|
||||
<dynamic-form target="dataSource" type="data_sources" actions="actions">
|
||||
</dynamic-form>
|
||||
</div>
|
||||
</div>
|
||||
</settings-screen>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user