mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-08 18:44:10 +00:00
Compare commits
624 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
562a24b651 | ||
![]() |
9318eb3fb2 | ||
![]() |
0590b76cd0 | ||
![]() |
8aac18c96d | ||
![]() |
6a6b8567c0 | ||
![]() |
78438bdfcc | ||
![]() |
2096c956db | ||
![]() |
dfc2d4d4f1 | ||
![]() |
5f57b72d6e | ||
![]() |
6a470b44e7 | ||
![]() |
a35a2ec8b7 | ||
![]() |
978b71c8bb | ||
![]() |
f3b328a4d8 | ||
![]() |
9da78880e6 | ||
![]() |
f2871cfc3c | ||
![]() |
24efd12ab5 | ||
![]() |
9b2e6b1c1d | ||
![]() |
9f62d83568 | ||
![]() |
7eb3575502 | ||
![]() |
6afa0d6311 | ||
![]() |
d170601678 | ||
![]() |
01a33d150f | ||
![]() |
28b11d2165 | ||
![]() |
83b5e8f3da | ||
![]() |
e4e4bf5ff4 | ||
![]() |
ab3a01b9f6 | ||
![]() |
8548472b6e | ||
![]() |
ab776e3989 | ||
![]() |
b0b7378e2b | ||
![]() |
75e03ef1d9 | ||
![]() |
986de0b5db | ||
![]() |
6959c9dde3 | ||
![]() |
53087e50e4 | ||
![]() |
dee830a56f | ||
![]() |
45179c53b7 | ||
![]() |
6c7cb65224 | ||
![]() |
0038b3c2e8 | ||
![]() |
17bb8a9ba4 | ||
![]() |
9756c13f13 | ||
![]() |
be84b06ca6 | ||
![]() |
3ee88d99da | ||
![]() |
16b0a2ac3b | ||
![]() |
a82d2e4903 | ||
![]() |
cef241a80b | ||
![]() |
eab6a1a112 | ||
![]() |
827bfc99ec | ||
![]() |
b5bd307999 | ||
![]() |
d02d029e88 | ||
![]() |
e41885c458 | ||
![]() |
78ac9946e3 | ||
![]() |
bd8e8eaa09 | ||
![]() |
a2a01755ec | ||
![]() |
a593c6187f | ||
![]() |
b159cbfeef | ||
![]() |
a7cced506b | ||
![]() |
e8469af763 | ||
![]() |
d2c6b23bf9 | ||
![]() |
c657498d75 | ||
![]() |
001d0c9af1 | ||
![]() |
0d0368c042 | ||
![]() |
6fdd0ff7c5 | ||
![]() |
dee671b640 | ||
![]() |
c289a422c3 | ||
![]() |
fa47529cf1 | ||
![]() |
6769de5b01 | ||
![]() |
c8a8892292 | ||
![]() |
273b25cb6f | ||
![]() |
71e06a4cd7 | ||
![]() |
827310aca6 | ||
![]() |
b9c83cf7ab | ||
![]() |
8a44748324 | ||
![]() |
e4f469ef73 | ||
![]() |
4cf4566fff | ||
![]() |
55c866f340 | ||
![]() |
fd550e223e | ||
![]() |
225ace636a | ||
![]() |
ee4c8b835b | ||
![]() |
f43ad0c176 | ||
![]() |
95de2618a3 | ||
![]() |
48c9f6ca50 | ||
![]() |
2d56bb74eb | ||
![]() |
42cc3cae8e | ||
![]() |
02da11e06e | ||
![]() |
eb1061a910 | ||
![]() |
d852d9e37c | ||
![]() |
1392aee195 | ||
![]() |
63b7defe1a | ||
![]() |
00803f039a | ||
![]() |
2b1c246c13 | ||
![]() |
4f6dd69cb1 | ||
![]() |
4fde1d2b65 | ||
![]() |
ccceebe257 | ||
![]() |
499dac9ee2 | ||
![]() |
1d26e61f7e | ||
![]() |
c40e20a3e3 | ||
![]() |
549b2b8e95 | ||
![]() |
15665c0363 | ||
![]() |
226f993e7d | ||
![]() |
9081265dab | ||
![]() |
de14f18be8 | ||
![]() |
da276f0c6b | ||
![]() |
5a3c98a849 | ||
![]() |
51fa0af3fe | ||
![]() |
fcdce01ae6 | ||
![]() |
9af9a7bff7 | ||
![]() |
1eef021704 | ||
![]() |
a308a0c9b4 | ||
![]() |
726b8243eb | ||
![]() |
88cea51561 | ||
![]() |
ec0bcb1f1b | ||
![]() |
2df1808c4e | ||
![]() |
c85e90a71b | ||
![]() |
1013a49db2 | ||
![]() |
f6eec29aa2 | ||
![]() |
64b99d5587 | ||
![]() |
75b07fc0d6 | ||
![]() |
d64068da66 | ||
![]() |
62b38934e5 | ||
![]() |
14d8e23135 | ||
![]() |
0908a15848 | ||
![]() |
2e878fb5ca | ||
![]() |
62d24341ca | ||
![]() |
f2085fdf0f | ||
![]() |
a19c211612 | ||
![]() |
9bbcf594ea | ||
![]() |
da89155503 | ||
![]() |
3b59c4861f | ||
![]() |
6f5764fd3d | ||
![]() |
3c059f3acf | ||
![]() |
3a022f1ae3 | ||
![]() |
049a59f2ed | ||
![]() |
ed9ea4e6cc | ||
![]() |
c415be2db3 | ||
![]() |
2bc5061e22 | ||
![]() |
cedf12baeb | ||
![]() |
b403c41c15 | ||
![]() |
acd75d85c7 | ||
![]() |
5e5dad9512 | ||
![]() |
95e343395d | ||
![]() |
6a29e5193b | ||
![]() |
1cb7177597 | ||
![]() |
50e863ca52 | ||
![]() |
8cdd7ca2d2 | ||
![]() |
6fbf8411ec | ||
![]() |
fa200fed98 | ||
![]() |
7d7d30bcae | ||
![]() |
85a4bbc28e | ||
![]() |
0b161627c2 | ||
![]() |
36e7898ed4 | ||
![]() |
3537722208 | ||
![]() |
dfcaa29c8a | ||
![]() |
92c6d69bc8 | ||
![]() |
7b8a2ae57b | ||
![]() |
b444fe478c | ||
![]() |
50fb1a016c | ||
![]() |
e229c63e11 | ||
![]() |
9649a57e34 | ||
![]() |
ac85d63013 | ||
![]() |
4b2ba1f6c0 | ||
![]() |
886d15b622 | ||
![]() |
d517ce37e7 | ||
![]() |
85f0cec33e | ||
![]() |
5c37569b2a | ||
![]() |
956b96967e | ||
![]() |
f51faa25ed | ||
![]() |
aa8c85f404 | ||
![]() |
98540f0f6d | ||
![]() |
841c89769a | ||
![]() |
84cb9761e8 | ||
![]() |
178e340223 | ||
![]() |
b18a05c2c8 | ||
![]() |
3466de1473 | ||
![]() |
4be4e41fa7 | ||
![]() |
3264463366 | ||
![]() |
8252504dad | ||
![]() |
ac3ef1efc1 | ||
![]() |
54cb259882 | ||
![]() |
04d0291fa0 | ||
![]() |
c8d6700406 | ||
![]() |
e61f2d74a8 | ||
![]() |
a0b9a1fe86 | ||
![]() |
27d2e95c43 | ||
![]() |
819e59292d | ||
![]() |
f3ef16b948 | ||
![]() |
5e1e44057d | ||
![]() |
bf2e322c22 | ||
![]() |
585b47051f | ||
![]() |
5ca96fa758 | ||
![]() |
aba6eb962f | ||
![]() |
107dc02fd0 | ||
![]() |
debac715bf | ||
![]() |
c6ed41e322 | ||
![]() |
ec2c90c73f | ||
![]() |
6c2c5e5a90 | ||
![]() |
f0b2d8c4eb | ||
![]() |
a588a0bfa3 | ||
![]() |
c07358a526 | ||
![]() |
9058fa42dd | ||
![]() |
55d7ebe006 | ||
![]() |
6edbfdad89 | ||
![]() |
715a4a25cf | ||
![]() |
e15447c8b8 | ||
![]() |
ab8eec164c | ||
![]() |
b1177cd2ce | ||
![]() |
40d95dc142 | ||
![]() |
d78bd42cfc | ||
![]() |
b6210dc225 | ||
![]() |
b05a89a3e0 | ||
![]() |
13e99b904b | ||
![]() |
01a4b9c4b4 | ||
![]() |
d6df1be272 | ||
![]() |
85ef5cf807 | ||
![]() |
ff020cb5a4 | ||
![]() |
5c54ac9aa1 | ||
![]() |
e48662423a | ||
![]() |
f124f06c2d | ||
![]() |
f2faf0ee43 | ||
![]() |
ab2913008e | ||
![]() |
eebc0f485d | ||
![]() |
bb6427ea9b | ||
![]() |
29b73563dc | ||
![]() |
aa0ce1c88a | ||
![]() |
7a9778249f | ||
![]() |
c41b732fbd | ||
![]() |
d9b85a819e | ||
![]() |
6d00eb501a | ||
![]() |
318c95342d | ||
![]() |
cde0f12f07 | ||
![]() |
6668fb39f9 | ||
![]() |
4691fae90a | ||
![]() |
0fccbc69ff | ||
![]() |
d699f794ac | ||
![]() |
29a9ca18fe | ||
![]() |
72ae21d6dc | ||
![]() |
310d9621e5 | ||
![]() |
0f4258d00c | ||
![]() |
78b5aa150c | ||
![]() |
3cfb14b9e5 | ||
![]() |
7e22614a4e | ||
![]() |
66ecaf472a | ||
![]() |
3ba262f6f6 | ||
![]() |
b935190da8 | ||
![]() |
7cd5c1c12b | ||
![]() |
4708fce4f8 | ||
![]() |
93fda7c96b | ||
![]() |
912e0ad53f | ||
![]() |
3e9ce8bc03 | ||
![]() |
a08aa3398c | ||
![]() |
3076845927 | ||
![]() |
cb25d29b0b | ||
![]() |
2e8d303ad8 | ||
![]() |
a754d56433 | ||
![]() |
775a16dc50 | ||
![]() |
16824dcadb | ||
![]() |
f949cda227 | ||
![]() |
454e356e4d | ||
![]() |
9a87b59e84 | ||
![]() |
93d82a9012 | ||
![]() |
564458b106 | ||
![]() |
b38e9c45bf | ||
![]() |
85d4c5bd7a | ||
![]() |
6a9d27ceb4 | ||
![]() |
d2eaf90df2 | ||
![]() |
fa8cd90793 | ||
![]() |
7dafae29fb | ||
![]() |
89a6c745b5 | ||
![]() |
821d9e229d | ||
![]() |
db7619fa7a | ||
![]() |
1ed9423530 | ||
![]() |
147a578f7a | ||
![]() |
3a59a15164 | ||
![]() |
1b7aded7f9 | ||
![]() |
bc45d77365 | ||
![]() |
1b3b005ca4 | ||
![]() |
e0be811b2c | ||
![]() |
3627251246 | ||
![]() |
8d056170d7 | ||
![]() |
3590d16e30 | ||
![]() |
572d258cd2 | ||
![]() |
11d0954551 | ||
![]() |
650d47d5c1 | ||
![]() |
945fc824d8 | ||
![]() |
a8aa737b00 | ||
![]() |
cd689a1fab | ||
![]() |
b3f04e7c66 | ||
![]() |
fbcf857991 | ||
![]() |
6c5e5452bc | ||
![]() |
2f5ba7ba30 | ||
![]() |
a045eefa64 | ||
![]() |
6ea4f2af0d | ||
![]() |
3d3ad2929c | ||
![]() |
00287ff5ba | ||
![]() |
805d063d1d | ||
![]() |
e6bacf7109 | ||
![]() |
33ccfa7963 | ||
![]() |
fdf23600c0 | ||
![]() |
593404f558 | ||
![]() |
e7bc282c80 | ||
![]() |
b939b482a1 | ||
![]() |
8afc2c9ae9 | ||
![]() |
d11eda14ed | ||
![]() |
ab79251fe2 | ||
![]() |
484dbf48de | ||
![]() |
6eb526d08a | ||
![]() |
e0a17db5f1 | ||
![]() |
45132b7244 | ||
![]() |
c23cddeb51 | ||
![]() |
672e19a22a | ||
![]() |
4a6c9c3a01 | ||
![]() |
2b79bc44da | ||
![]() |
7861662e17 | ||
![]() |
4a1842bf8a | ||
![]() |
8f18303e50 | ||
![]() |
bcad6e287d | ||
![]() |
9de1951952 | ||
![]() |
99cb1f17f0 | ||
![]() |
10d5157e95 | ||
![]() |
2fc4f26832 | ||
![]() |
f6230001bb | ||
![]() |
960f7cbeb9 | ||
![]() |
76f06a6b55 | ||
![]() |
9c498aa5d4 | ||
![]() |
a0b60f9118 | ||
![]() |
27cb56429b | ||
![]() |
b1ffd4b10b | ||
![]() |
a9ea064202 | ||
![]() |
687a41a467 | ||
![]() |
5348451b2e | ||
![]() |
55f0579dcc | ||
![]() |
a3ea0f0449 | ||
![]() |
78492a4a8e | ||
![]() |
f22203f50e | ||
![]() |
500b354a00 | ||
![]() |
9d4093782f | ||
![]() |
43b3cebfff | ||
![]() |
63c381431d | ||
![]() |
bf41767b33 | ||
![]() |
83d6e4e993 | ||
![]() |
d64a2ddd95 | ||
![]() |
392176d873 | ||
![]() |
58420b3307 | ||
![]() |
a5e3b66dee | ||
![]() |
a9fbe5c9f6 | ||
![]() |
71bbbe4a67 | ||
![]() |
3843885382 | ||
![]() |
25ea559e0d | ||
![]() |
c18815de91 | ||
![]() |
50d53667ce | ||
![]() |
68e2baf4aa | ||
![]() |
6fc9d40e51 | ||
![]() |
0b25694b40 | ||
![]() |
bf750e488f | ||
![]() |
359f9055fc | ||
![]() |
b84dd5d735 | ||
![]() |
3ed345f496 | ||
![]() |
6633f8ef28 | ||
![]() |
757053a9ec | ||
![]() |
36cad38ddf | ||
![]() |
1e9a1cb621 | ||
![]() |
9f051d3172 | ||
![]() |
53576c8f82 | ||
![]() |
bb5ec39b2f | ||
![]() |
4c54c6dcc8 | ||
![]() |
39da98f133 | ||
![]() |
29e69aa880 | ||
![]() |
0c315f31b7 | ||
![]() |
508842a68c | ||
![]() |
4b31615a05 | ||
![]() |
17b64280e8 | ||
![]() |
88be7a9967 | ||
![]() |
4ca2344af7 | ||
![]() |
4c050cf165 | ||
![]() |
5e2ccb81fa | ||
![]() |
b8dc3befab | ||
![]() |
2f29848757 | ||
![]() |
4f3d6cdd0c | ||
![]() |
67733ef928 | ||
![]() |
e657754e7f | ||
![]() |
2d6087959c | ||
![]() |
08a913707f | ||
![]() |
9c8a4f287a | ||
![]() |
64d6f0222c | ||
![]() |
538abdf084 | ||
![]() |
144abcb965 | ||
![]() |
6e5c307edb | ||
![]() |
67ebe0b0cf | ||
![]() |
dcf21d53fd | ||
![]() |
f5bb0a2622 | ||
![]() |
704712cc81 | ||
![]() |
f86d53a234 | ||
![]() |
5466224988 | ||
![]() |
f9fa21bfd7 | ||
![]() |
e4855c30f5 | ||
![]() |
f1c4fdd5a2 | ||
![]() |
e58cf06706 | ||
![]() |
91f4918cff | ||
![]() |
b15ccfa4ae | ||
![]() |
5102fde2f0 | ||
![]() |
f5dc005a70 | ||
![]() |
5fd8f0f596 | ||
![]() |
26ceafa8a3 | ||
![]() |
2e2ed8a4ff | ||
![]() |
6cc734f884 | ||
![]() |
4f7f07d3b7 | ||
![]() |
d436c97e3d | ||
![]() |
807c5b8ff9 | ||
![]() |
8da06d1259 | ||
![]() |
1c1be8a24b | ||
![]() |
897606b00c | ||
![]() |
615af5eb33 | ||
![]() |
85f94c12fc | ||
![]() |
ccfee4d235 | ||
![]() |
a2ba55756d | ||
![]() |
1b3e94db6c | ||
![]() |
614d9d89d0 | ||
![]() |
05a3f5aa9a | ||
![]() |
4f47153123 | ||
![]() |
a14d9ecaa1 | ||
![]() |
6815f30d36 | ||
![]() |
13172e6856 | ||
![]() |
ebc9fd7758 | ||
![]() |
0761a5db02 | ||
![]() |
46e7a231fe | ||
![]() |
ffa5a20e2f | ||
![]() |
2088a57ffe | ||
![]() |
345805781f | ||
![]() |
9eb52ea788 | ||
![]() |
fb1405ecd8 | ||
![]() |
3f01bf400b | ||
![]() |
c528751502 | ||
![]() |
0018184150 | ||
![]() |
7903f76e11 | ||
![]() |
d5551a2f32 | ||
![]() |
ca564a5948 | ||
![]() |
0fcc559323 | ||
![]() |
a746e8e7fb | ||
![]() |
b2ce6023e1 | ||
![]() |
39b331df1b | ||
![]() |
a69140ae1b | ||
![]() |
225ca9007a | ||
![]() |
11efebf1e2 | ||
![]() |
3e5082f265 | ||
![]() |
36cb1df27e | ||
![]() |
fcad2d5695 | ||
![]() |
2ec722d3af | ||
![]() |
390f50e246 | ||
![]() |
3276e4a58f | ||
![]() |
2a8428dbb0 | ||
![]() |
7febb3aa06 | ||
![]() |
92c6a23a13 | ||
![]() |
bb75081086 | ||
![]() |
915c244d02 | ||
![]() |
b5e0f46796 | ||
![]() |
34e8e2d828 | ||
![]() |
c2cbeda9e4 | ||
![]() |
92a33bd358 | ||
![]() |
e19700348d | ||
![]() |
04ac02c09d | ||
![]() |
2b61c16c06 | ||
![]() |
028722a5ac | ||
![]() |
ca7e07de54 | ||
![]() |
c523e74644 | ||
![]() |
dd932784ed | ||
![]() |
4704217dc5 | ||
![]() |
3893fb6d2c | ||
![]() |
59b2b36a27 | ||
![]() |
f6eaaebdf4 | ||
![]() |
bb20002aea | ||
![]() |
d1995ba7eb | ||
![]() |
b06f4cda33 | ||
![]() |
9d7a235107 | ||
![]() |
18459bad11 | ||
![]() |
ced941a6aa | ||
![]() |
85e37e7f8c | ||
![]() |
53067de596 | ||
![]() |
9c13861eb8 | ||
![]() |
b0ed9f5928 | ||
![]() |
ff0d15fa43 | ||
![]() |
81bb05d0ef | ||
![]() |
95649a3ece | ||
![]() |
08288f5b0f | ||
![]() |
01b1ce3995 | ||
![]() |
cbe93810be | ||
![]() |
75309d9dc4 | ||
![]() |
8594b3fa70 | ||
![]() |
1e956df4c7 | ||
![]() |
8ba2bcdfd4 | ||
![]() |
999cc0a37c | ||
![]() |
a6611e5999 | ||
![]() |
c0d5778d93 | ||
![]() |
293fe4e838 | ||
![]() |
dfee471e22 | ||
![]() |
db7cdc4aa7 | ||
![]() |
c048ad4aac | ||
![]() |
9e245379e8 | ||
![]() |
496f414a2e | ||
![]() |
df67a75893 | ||
![]() |
249b4af59f | ||
![]() |
db3b2d8961 | ||
![]() |
7d44a0ffc8 | ||
![]() |
202b2590e9 | ||
![]() |
c98ef547a8 | ||
![]() |
8a866a9102 | ||
![]() |
b186bdbce3 | ||
![]() |
36fe6c6f66 | ||
![]() |
8bf559db52 | ||
![]() |
750085f627 | ||
![]() |
2dc2c99b4a | ||
![]() |
e703555888 | ||
![]() |
7e102f0511 | ||
![]() |
facde96425 | ||
![]() |
608c746a59 | ||
![]() |
a8c834410f | ||
![]() |
bda14b487a | ||
![]() |
fd5cf8c360 | ||
![]() |
03758e5b46 | ||
![]() |
e540d143bb | ||
![]() |
b2c5ad40c5 | ||
![]() |
edfdf672d8 | ||
![]() |
39f19aef49 | ||
![]() |
8813bb63d4 | ||
![]() |
7c18d6fe14 | ||
![]() |
d1fe17d3cb | ||
![]() |
b8965c2017 | ||
![]() |
733d7bc158 | ||
![]() |
88f31c29bb | ||
![]() |
3caf3cfda8 | ||
![]() |
d076c55cca | ||
![]() |
3e185022c8 | ||
![]() |
857ee2885f | ||
![]() |
cd8dd56213 | ||
![]() |
f06902aa8f | ||
![]() |
bb109c6f75 | ||
![]() |
e525ec7b5b | ||
![]() |
356b98e19f | ||
![]() |
8c803e7a53 | ||
![]() |
2e21a6f4e0 | ||
![]() |
cfd31b14e3 | ||
![]() |
f03a620424 | ||
![]() |
440ad77ad5 | ||
![]() |
68835e97a2 | ||
![]() |
ce80c9c9cf | ||
![]() |
3c299fbfb7 | ||
![]() |
597f8ea6eb | ||
![]() |
d1181085bf | ||
![]() |
913832da48 | ||
![]() |
42f57f4a72 | ||
![]() |
d01a518c41 | ||
![]() |
65ce06b116 | ||
![]() |
468aa5e93c | ||
![]() |
5c01370e6f | ||
![]() |
21d08883a8 | ||
![]() |
59de506f20 | ||
![]() |
b34120ed81 | ||
![]() |
617978179d | ||
![]() |
0985d6fdf2 | ||
![]() |
2049fb0491 | ||
![]() |
a58fc6534b | ||
![]() |
a14f97b7aa | ||
![]() |
0a4cd5b4f2 | ||
![]() |
dca6d372df | ||
![]() |
3898c72921 | ||
![]() |
b25517efe8 | ||
![]() |
392dffd11e | ||
![]() |
510f6ea7e6 | ||
![]() |
296a0ad2f2 | ||
![]() |
487c4524ad | ||
![]() |
b2f0208fcc | ||
![]() |
84b9c3848c | ||
![]() |
9adbafdfb3 | ||
![]() |
9cf2b5101e | ||
![]() |
725fa3a48a | ||
![]() |
534dda3dc7 | ||
![]() |
b0c7df04ac | ||
![]() |
61b0e8bef5 | ||
![]() |
64f3938528 | ||
![]() |
85bc92d88e | ||
![]() |
7bcda18564 | ||
![]() |
86da36857e | ||
![]() |
530833e930 | ||
![]() |
3b0850fa9b | ||
![]() |
1366911be6 | ||
![]() |
fe276eac64 | ||
![]() |
9209ccd0de | ||
![]() |
3b2a1a37f9 | ||
![]() |
6007ba78b0 | ||
![]() |
9cb19cc342 | ||
![]() |
0f471f4e12 | ||
![]() |
68db740998 | ||
![]() |
9c0c6f25b7 | ||
![]() |
5f0077cb5b | ||
![]() |
a6a2056cca | ||
![]() |
fb1e81212f | ||
![]() |
17f811d0b4 | ||
![]() |
34398d94de | ||
![]() |
6bf94fde48 | ||
![]() |
ee18fed04b | ||
![]() |
28f56ba510 | ||
![]() |
c8d3dbb7b1 | ||
![]() |
a76a093638 | ||
![]() |
27908a8e17 | ||
![]() |
8a30f015c9 | ||
![]() |
8cac83fc96 | ||
![]() |
9ade4bb9b2 | ||
![]() |
874c91a086 | ||
![]() |
a906677440 | ||
![]() |
3f93942a24 | ||
![]() |
aeb3130b25 | ||
![]() |
8a6b364ca5 | ||
![]() |
2ade7328d1 | ||
![]() |
2bb9f4f444 | ||
![]() |
b029d983f9 | ||
![]() |
4082006039 | ||
![]() |
69aa0eaa7a | ||
![]() |
3674ada640 | ||
![]() |
48accb0a64 | ||
![]() |
70ac143cfe | ||
![]() |
b1b2d531f8 | ||
![]() |
e200783c59 | ||
![]() |
a7e57196c6 | ||
![]() |
b5f05e6cd2 | ||
![]() |
5fe5b35f21 | ||
![]() |
3610454a12 | ||
![]() |
abc4bbebe4 |
15
.github/dependabot.yml
vendored
Normal file
15
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# dependabot.yml reference: https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
||||||
|
#
|
||||||
|
# Notes:
|
||||||
|
# - Status and logs from dependabot are provided at
|
||||||
|
# https://github.com/jupyterhub/jupyterhub/network/updates.
|
||||||
|
#
|
||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
# Maintain dependencies in our GitHub Workflows
|
||||||
|
- package-ecosystem: github-actions
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
|
time: "05:00"
|
||||||
|
timezone: "Etc/UTC"
|
121
.github/workflows/release.yml
vendored
121
.github/workflows/release.yml
vendored
@@ -1,31 +1,49 @@
|
|||||||
# Build releases and (on tags) publish to PyPI
|
# This is a GitHub workflow defining a set of jobs with a set of steps.
|
||||||
|
# ref: https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions
|
||||||
|
#
|
||||||
|
# Test build release artifacts (PyPI package, Docker images) and publish them on
|
||||||
|
# pushed git tags.
|
||||||
|
#
|
||||||
name: Release
|
name: Release
|
||||||
|
|
||||||
# always build releases (to make sure wheel-building works)
|
|
||||||
# but only publish to PyPI on tags
|
|
||||||
on:
|
on:
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- "!dependabot/**"
|
|
||||||
tags:
|
|
||||||
- "*"
|
|
||||||
pull_request:
|
pull_request:
|
||||||
|
paths-ignore:
|
||||||
|
- "docs/**"
|
||||||
|
- "**.md"
|
||||||
|
- "**.rst"
|
||||||
|
- ".github/workflows/*"
|
||||||
|
- "!.github/workflows/release.yml"
|
||||||
|
push:
|
||||||
|
paths-ignore:
|
||||||
|
- "docs/**"
|
||||||
|
- "**.md"
|
||||||
|
- "**.rst"
|
||||||
|
- ".github/workflows/*"
|
||||||
|
- "!.github/workflows/release.yml"
|
||||||
|
branches-ignore:
|
||||||
|
- "dependabot/**"
|
||||||
|
- "pre-commit-ci-update-config"
|
||||||
|
tags:
|
||||||
|
- "**"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-release:
|
build-release:
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-20.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
- uses: actions/setup-python@v2
|
- uses: actions/setup-python@v4
|
||||||
with:
|
with:
|
||||||
python-version: 3.8
|
python-version: "3.9"
|
||||||
|
|
||||||
- uses: actions/setup-node@v1
|
- uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: "14"
|
node-version: "14"
|
||||||
|
|
||||||
- name: install build package
|
- name: install build requirements
|
||||||
run: |
|
run: |
|
||||||
|
npm install -g yarn
|
||||||
pip install --upgrade pip
|
pip install --upgrade pip
|
||||||
pip install build
|
pip install build
|
||||||
pip freeze
|
pip freeze
|
||||||
@@ -35,28 +53,17 @@ jobs:
|
|||||||
python -m build --sdist --wheel .
|
python -m build --sdist --wheel .
|
||||||
ls -l dist
|
ls -l dist
|
||||||
|
|
||||||
- name: verify wheel
|
- name: verify sdist
|
||||||
run: |
|
run: |
|
||||||
cd dist
|
./ci/check_sdist.py dist/jupyterhub-*.tar.gz
|
||||||
pip install ./*.whl
|
|
||||||
# verify data-files are installed where they are found
|
- name: verify data-files are installed where they are found
|
||||||
cat <<EOF | python
|
run: |
|
||||||
import os
|
pip install dist/*.whl
|
||||||
from jupyterhub._data import DATA_FILES_PATH
|
./ci/check_installed_data.py
|
||||||
print(f"DATA_FILES_PATH={DATA_FILES_PATH}")
|
|
||||||
assert os.path.exists(DATA_FILES_PATH), DATA_FILES_PATH
|
|
||||||
for subpath in (
|
|
||||||
"templates/page.html",
|
|
||||||
"static/css/style.min.css",
|
|
||||||
"static/components/jquery/dist/jquery.js",
|
|
||||||
):
|
|
||||||
path = os.path.join(DATA_FILES_PATH, subpath)
|
|
||||||
assert os.path.exists(path), path
|
|
||||||
print("OK")
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# ref: https://github.com/actions/upload-artifact#readme
|
# ref: https://github.com/actions/upload-artifact#readme
|
||||||
- uses: actions/upload-artifact@v2
|
- uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: jupyterhub-${{ github.sha }}
|
name: jupyterhub-${{ github.sha }}
|
||||||
path: "dist/*"
|
path: "dist/*"
|
||||||
@@ -91,17 +98,16 @@ jobs:
|
|||||||
echo "REGISTRY=localhost:5000/" >> $GITHUB_ENV
|
echo "REGISTRY=localhost:5000/" >> $GITHUB_ENV
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
# Setup docker to build for multiple platforms, see:
|
# Setup docker to build for multiple platforms, see:
|
||||||
# https://github.com/docker/build-push-action/tree/v2.4.0#usage
|
# https://github.com/docker/build-push-action/tree/v2.4.0#usage
|
||||||
# https://github.com/docker/build-push-action/blob/v2.4.0/docs/advanced/multi-platform.md
|
# https://github.com/docker/build-push-action/blob/v2.4.0/docs/advanced/multi-platform.md
|
||||||
|
|
||||||
- name: Set up QEMU (for docker buildx)
|
- name: Set up QEMU (for docker buildx)
|
||||||
uses: docker/setup-qemu-action@25f0500ff22e406f7191a2a8ba8cda16901ca018 # associated tag: v1.0.2
|
uses: docker/setup-qemu-action@8b122486cedac8393e77aa9734c3528886e4a1a8 # associated tag: v1.0.2
|
||||||
|
|
||||||
- name: Set up Docker Buildx (for multi-arch builds)
|
- name: Set up Docker Buildx (for multi-arch builds)
|
||||||
uses: docker/setup-buildx-action@2a4b53665e15ce7d7049afb11ff1f70ff1610609 # associated tag: v1.1.2
|
uses: docker/setup-buildx-action@dc7b9719a96d48369863986a06765841d7ea23f6 # associated tag: v1.1.2
|
||||||
with:
|
with:
|
||||||
# Allows pushing to registry on localhost:5000
|
# Allows pushing to registry on localhost:5000
|
||||||
driver-opts: network=host
|
driver-opts: network=host
|
||||||
@@ -120,6 +126,8 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" -p "${{ secrets.DOCKERHUB_TOKEN }}"
|
docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" -p "${{ secrets.DOCKERHUB_TOKEN }}"
|
||||||
|
|
||||||
|
# image: jupyterhub/jupyterhub
|
||||||
|
#
|
||||||
# https://github.com/jupyterhub/action-major-minor-tag-calculator
|
# https://github.com/jupyterhub/action-major-minor-tag-calculator
|
||||||
# If this is a tagged build this will return additional parent tags.
|
# If this is a tagged build this will return additional parent tags.
|
||||||
# E.g. 1.2.3 is expanded to Docker tags
|
# E.g. 1.2.3 is expanded to Docker tags
|
||||||
@@ -129,7 +137,7 @@ jobs:
|
|||||||
# If GITHUB_TOKEN isn't available (e.g. in PRs) returns no tags [].
|
# If GITHUB_TOKEN isn't available (e.g. in PRs) returns no tags [].
|
||||||
- name: Get list of jupyterhub tags
|
- name: Get list of jupyterhub tags
|
||||||
id: jupyterhubtags
|
id: jupyterhubtags
|
||||||
uses: jupyterhub/action-major-minor-tag-calculator@v1
|
uses: jupyterhub/action-major-minor-tag-calculator@v2
|
||||||
with:
|
with:
|
||||||
githubToken: ${{ secrets.GITHUB_TOKEN }}
|
githubToken: ${{ secrets.GITHUB_TOKEN }}
|
||||||
prefix: "${{ env.REGISTRY }}jupyterhub/jupyterhub:"
|
prefix: "${{ env.REGISTRY }}jupyterhub/jupyterhub:"
|
||||||
@@ -137,7 +145,7 @@ jobs:
|
|||||||
branchRegex: ^\w[\w-.]*$
|
branchRegex: ^\w[\w-.]*$
|
||||||
|
|
||||||
- name: Build and push jupyterhub
|
- name: Build and push jupyterhub
|
||||||
uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f # associated tag: v2.4.0
|
uses: docker/build-push-action@1cb9d22b932e4832bb29793b7777ec860fc1cde0
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
@@ -146,11 +154,11 @@ jobs:
|
|||||||
# array into a comma separated list of tags
|
# array into a comma separated list of tags
|
||||||
tags: ${{ join(fromJson(steps.jupyterhubtags.outputs.tags)) }}
|
tags: ${{ join(fromJson(steps.jupyterhubtags.outputs.tags)) }}
|
||||||
|
|
||||||
# jupyterhub-onbuild
|
# image: jupyterhub/jupyterhub-onbuild
|
||||||
|
#
|
||||||
- name: Get list of jupyterhub-onbuild tags
|
- name: Get list of jupyterhub-onbuild tags
|
||||||
id: onbuildtags
|
id: onbuildtags
|
||||||
uses: jupyterhub/action-major-minor-tag-calculator@v1
|
uses: jupyterhub/action-major-minor-tag-calculator@v2
|
||||||
with:
|
with:
|
||||||
githubToken: ${{ secrets.GITHUB_TOKEN }}
|
githubToken: ${{ secrets.GITHUB_TOKEN }}
|
||||||
prefix: "${{ env.REGISTRY }}jupyterhub/jupyterhub-onbuild:"
|
prefix: "${{ env.REGISTRY }}jupyterhub/jupyterhub-onbuild:"
|
||||||
@@ -158,7 +166,7 @@ jobs:
|
|||||||
branchRegex: ^\w[\w-.]*$
|
branchRegex: ^\w[\w-.]*$
|
||||||
|
|
||||||
- name: Build and push jupyterhub-onbuild
|
- name: Build and push jupyterhub-onbuild
|
||||||
uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f # associated tag: v2.4.0
|
uses: docker/build-push-action@1cb9d22b932e4832bb29793b7777ec860fc1cde0
|
||||||
with:
|
with:
|
||||||
build-args: |
|
build-args: |
|
||||||
BASE_IMAGE=${{ fromJson(steps.jupyterhubtags.outputs.tags)[0] }}
|
BASE_IMAGE=${{ fromJson(steps.jupyterhubtags.outputs.tags)[0] }}
|
||||||
@@ -167,11 +175,11 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
tags: ${{ join(fromJson(steps.onbuildtags.outputs.tags)) }}
|
tags: ${{ join(fromJson(steps.onbuildtags.outputs.tags)) }}
|
||||||
|
|
||||||
# jupyterhub-demo
|
# image: jupyterhub/jupyterhub-demo
|
||||||
|
#
|
||||||
- name: Get list of jupyterhub-demo tags
|
- name: Get list of jupyterhub-demo tags
|
||||||
id: demotags
|
id: demotags
|
||||||
uses: jupyterhub/action-major-minor-tag-calculator@v1
|
uses: jupyterhub/action-major-minor-tag-calculator@v2
|
||||||
with:
|
with:
|
||||||
githubToken: ${{ secrets.GITHUB_TOKEN }}
|
githubToken: ${{ secrets.GITHUB_TOKEN }}
|
||||||
prefix: "${{ env.REGISTRY }}jupyterhub/jupyterhub-demo:"
|
prefix: "${{ env.REGISTRY }}jupyterhub/jupyterhub-demo:"
|
||||||
@@ -179,7 +187,7 @@ jobs:
|
|||||||
branchRegex: ^\w[\w-.]*$
|
branchRegex: ^\w[\w-.]*$
|
||||||
|
|
||||||
- name: Build and push jupyterhub-demo
|
- name: Build and push jupyterhub-demo
|
||||||
uses: docker/build-push-action@e1b7f96249f2e4c8e4ac1519b9608c0d48944a1f # associated tag: v2.4.0
|
uses: docker/build-push-action@1cb9d22b932e4832bb29793b7777ec860fc1cde0
|
||||||
with:
|
with:
|
||||||
build-args: |
|
build-args: |
|
||||||
BASE_IMAGE=${{ fromJson(steps.onbuildtags.outputs.tags)[0] }}
|
BASE_IMAGE=${{ fromJson(steps.onbuildtags.outputs.tags)[0] }}
|
||||||
@@ -190,3 +198,24 @@ jobs:
|
|||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ join(fromJson(steps.demotags.outputs.tags)) }}
|
tags: ${{ join(fromJson(steps.demotags.outputs.tags)) }}
|
||||||
|
|
||||||
|
# image: jupyterhub/singleuser
|
||||||
|
#
|
||||||
|
- name: Get list of jupyterhub/singleuser tags
|
||||||
|
id: singleusertags
|
||||||
|
uses: jupyterhub/action-major-minor-tag-calculator@v2
|
||||||
|
with:
|
||||||
|
githubToken: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
prefix: "${{ env.REGISTRY }}jupyterhub/singleuser:"
|
||||||
|
defaultTag: "${{ env.REGISTRY }}jupyterhub/singleuser:noref"
|
||||||
|
branchRegex: ^\w[\w-.]*$
|
||||||
|
|
||||||
|
- name: Build and push jupyterhub/singleuser
|
||||||
|
uses: docker/build-push-action@1cb9d22b932e4832bb29793b7777ec860fc1cde0
|
||||||
|
with:
|
||||||
|
build-args: |
|
||||||
|
JUPYTERHUB_VERSION=${{ github.ref_type == 'tag' && github.ref_name || format('git:{0}', github.sha) }}
|
||||||
|
context: singleuser
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
push: true
|
||||||
|
tags: ${{ join(fromJson(steps.singleusertags.outputs.tags)) }}
|
||||||
|
31
.github/workflows/support-bot.yml
vendored
Normal file
31
.github/workflows/support-bot.yml
vendored
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# https://github.com/dessant/support-requests
|
||||||
|
name: "Support Requests"
|
||||||
|
|
||||||
|
on:
|
||||||
|
issues:
|
||||||
|
types: [labeled, unlabeled, reopened]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
action:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: dessant/support-requests@v2
|
||||||
|
with:
|
||||||
|
github-token: ${{ github.token }}
|
||||||
|
support-label: "support"
|
||||||
|
issue-comment: |
|
||||||
|
Hi there @{issue-author} :wave:!
|
||||||
|
|
||||||
|
I closed this issue because it was labelled as a support question.
|
||||||
|
|
||||||
|
Please help us organize discussion by posting this on the http://discourse.jupyter.org/ forum.
|
||||||
|
|
||||||
|
Our goal is to sustain a positive experience for both users and developers. We use GitHub issues for specific discussions related to changing a repository's content, and let the forum be where we can more generally help and inspire each other.
|
||||||
|
|
||||||
|
Thanks you for being an active member of our community! :heart:
|
||||||
|
close-issue: true
|
||||||
|
lock-issue: false
|
||||||
|
issue-lock-reason: "off-topic"
|
62
.github/workflows/test-docs.yml
vendored
Normal file
62
.github/workflows/test-docs.yml
vendored
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# This is a GitHub workflow defining a set of jobs with a set of steps.
|
||||||
|
# ref: https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions
|
||||||
|
#
|
||||||
|
# This workflow validates the REST API definition and runs the pytest tests in
|
||||||
|
# the docs/ folder. This workflow does not build the documentation. That is
|
||||||
|
# instead tested via ReadTheDocs (https://readthedocs.org/projects/jupyterhub/).
|
||||||
|
#
|
||||||
|
name: Test docs
|
||||||
|
|
||||||
|
# The tests defined in docs/ are currently influenced by changes to _version.py
|
||||||
|
# and scopes.py.
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "docs/**"
|
||||||
|
- "jupyterhub/_version.py"
|
||||||
|
- "jupyterhub/scopes.py"
|
||||||
|
- ".github/workflows/test-docs.yml"
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- "docs/**"
|
||||||
|
- "jupyterhub/_version.py"
|
||||||
|
- "jupyterhub/scopes.py"
|
||||||
|
- ".github/workflows/test-docs.yml"
|
||||||
|
branches-ignore:
|
||||||
|
- "dependabot/**"
|
||||||
|
- "pre-commit-ci-update-config"
|
||||||
|
tags:
|
||||||
|
- "**"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
# UTF-8 content may be interpreted as ascii and causes errors without this.
|
||||||
|
LANG: C.UTF-8
|
||||||
|
PYTEST_ADDOPTS: "--verbose --color=yes"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
validate-rest-api-definition:
|
||||||
|
runs-on: ubuntu-20.04
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Validate REST API definition
|
||||||
|
uses: char0n/swagger-editor-validate@v1.3.1
|
||||||
|
with:
|
||||||
|
definition-file: docs/source/_static/rest-api.yml
|
||||||
|
|
||||||
|
test-docs:
|
||||||
|
runs-on: ubuntu-20.04
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- uses: actions/setup-python@v4
|
||||||
|
with:
|
||||||
|
python-version: "3.9"
|
||||||
|
|
||||||
|
- name: Install requirements
|
||||||
|
run: |
|
||||||
|
pip install -r docs/requirements.txt pytest -e .
|
||||||
|
|
||||||
|
- name: pytest docs/
|
||||||
|
run: |
|
||||||
|
pytest docs/
|
52
.github/workflows/test-jsx.yml
vendored
Normal file
52
.github/workflows/test-jsx.yml
vendored
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# This is a GitHub workflow defining a set of jobs with a set of steps.
|
||||||
|
# ref: https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions
|
||||||
|
#
|
||||||
|
name: Test jsx (admin-react.js)
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- "jsx/**"
|
||||||
|
- ".github/workflows/test-jsx.yml"
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- "jsx/**"
|
||||||
|
- ".github/workflows/test-jsx.yml"
|
||||||
|
branches-ignore:
|
||||||
|
- "dependabot/**"
|
||||||
|
- "pre-commit-ci-update-config"
|
||||||
|
tags:
|
||||||
|
- "**"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# The ./jsx folder contains React based source code files that are to compile
|
||||||
|
# to share/jupyterhub/static/js/admin-react.js. The ./jsx folder includes
|
||||||
|
# tests also has tests that this job is meant to run with `yarn test`
|
||||||
|
# according to the documentation in jsx/README.md.
|
||||||
|
test-jsx-admin-react:
|
||||||
|
runs-on: ubuntu-20.04
|
||||||
|
timeout-minutes: 5
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: "14"
|
||||||
|
|
||||||
|
- name: Install yarn
|
||||||
|
run: |
|
||||||
|
npm install -g yarn
|
||||||
|
|
||||||
|
- name: yarn
|
||||||
|
run: |
|
||||||
|
cd jsx
|
||||||
|
yarn
|
||||||
|
|
||||||
|
- name: yarn test
|
||||||
|
run: |
|
||||||
|
cd jsx
|
||||||
|
yarn test
|
97
.github/workflows/test.yml
vendored
97
.github/workflows/test.yml
vendored
@@ -1,25 +1,43 @@
|
|||||||
# This is a GitHub workflow defining a set of jobs with a set of steps.
|
# This is a GitHub workflow defining a set of jobs with a set of steps.
|
||||||
# ref: https://docs.github.com/en/free-pro-team@latest/actions/reference/workflow-syntax-for-github-actions
|
# ref: https://docs.github.com/en/actions/learn-github-actions/workflow-syntax-for-github-actions
|
||||||
#
|
#
|
||||||
name: Test
|
name: Test
|
||||||
|
|
||||||
# Trigger the workflow's on all PRs but only on pushed tags or commits to
|
|
||||||
# main/master branch to avoid PRs developed in a GitHub fork's dedicated branch
|
|
||||||
# to trigger.
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
|
paths-ignore:
|
||||||
|
- "docs/**"
|
||||||
|
- "**.md"
|
||||||
|
- "**.rst"
|
||||||
|
- ".github/workflows/*"
|
||||||
|
- "!.github/workflows/test.yml"
|
||||||
push:
|
push:
|
||||||
|
paths-ignore:
|
||||||
|
- "docs/**"
|
||||||
|
- "**.md"
|
||||||
|
- "**.rst"
|
||||||
|
- ".github/workflows/*"
|
||||||
|
- "!.github/workflows/test.yml"
|
||||||
|
branches-ignore:
|
||||||
|
- "dependabot/**"
|
||||||
|
- "pre-commit-ci-update-config"
|
||||||
|
tags:
|
||||||
|
- "**"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
# UTF-8 content may be interpreted as ascii and causes errors without this.
|
# UTF-8 content may be interpreted as ascii and causes errors without this.
|
||||||
LANG: C.UTF-8
|
LANG: C.UTF-8
|
||||||
|
PYTEST_ADDOPTS: "--verbose --color=yes"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# Run "pytest jupyterhub/tests" in various configurations
|
# Run "pytest jupyterhub/tests" in various configurations
|
||||||
pytest:
|
pytest:
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-20.04
|
||||||
timeout-minutes: 10
|
timeout-minutes: 15
|
||||||
|
|
||||||
strategy:
|
strategy:
|
||||||
# Keep running even if one variation of the job fail
|
# Keep running even if one variation of the job fail
|
||||||
@@ -38,9 +56,9 @@ jobs:
|
|||||||
# Tests everything when JupyterHub works against a dedicated mysql or
|
# Tests everything when JupyterHub works against a dedicated mysql or
|
||||||
# postgresql server.
|
# postgresql server.
|
||||||
#
|
#
|
||||||
# jupyter_server:
|
# legacy_notebook:
|
||||||
# Tests everything when the user instances are started with
|
# Tests everything when the user instances are started with
|
||||||
# jupyter_server instead of notebook.
|
# the legacy notebook server instead of jupyter_server.
|
||||||
#
|
#
|
||||||
# ssl:
|
# ssl:
|
||||||
# Tests everything using internal SSL connections instead of
|
# Tests everything using internal SSL connections instead of
|
||||||
@@ -48,25 +66,30 @@ jobs:
|
|||||||
#
|
#
|
||||||
# main_dependencies:
|
# main_dependencies:
|
||||||
# Tests everything when the we use the latest available dependencies
|
# Tests everything when the we use the latest available dependencies
|
||||||
# from: ipytraitlets.
|
# from: traitlets.
|
||||||
#
|
#
|
||||||
# NOTE: Since only the value of these parameters are presented in the
|
# NOTE: Since only the value of these parameters are presented in the
|
||||||
# GitHub UI when the workflow run, we avoid using true/false as
|
# GitHub UI when the workflow run, we avoid using true/false as
|
||||||
# values by instead duplicating the name to signal true.
|
# values by instead duplicating the name to signal true.
|
||||||
include:
|
include:
|
||||||
- python: "3.6"
|
- python: "3.7"
|
||||||
oldest_dependencies: oldest_dependencies
|
oldest_dependencies: oldest_dependencies
|
||||||
- python: "3.6"
|
legacy_notebook: legacy_notebook
|
||||||
subdomain: subdomain
|
|
||||||
- python: "3.7"
|
|
||||||
db: mysql
|
|
||||||
- python: "3.7"
|
|
||||||
ssl: ssl
|
|
||||||
- python: "3.8"
|
- python: "3.8"
|
||||||
db: postgres
|
legacy_notebook: legacy_notebook
|
||||||
- python: "3.8"
|
|
||||||
jupyter_server: jupyter_server
|
|
||||||
- python: "3.9"
|
- python: "3.9"
|
||||||
|
db: mysql
|
||||||
|
- python: "3.10"
|
||||||
|
db: postgres
|
||||||
|
- python: "3.10"
|
||||||
|
subdomain: subdomain
|
||||||
|
- python: "3.10"
|
||||||
|
ssl: ssl
|
||||||
|
# can't test 3.11.0-beta.4 until a greenlet release
|
||||||
|
# greenlet is a dependency of sqlalchemy on linux
|
||||||
|
# see https://github.com/gevent/gevent/issues/1867
|
||||||
|
# - python: "3.11.0-beta.4"
|
||||||
|
- python: "3.10"
|
||||||
main_dependencies: main_dependencies
|
main_dependencies: main_dependencies
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -94,26 +117,25 @@ jobs:
|
|||||||
if [ "${{ matrix.jupyter_server }}" != "" ]; then
|
if [ "${{ matrix.jupyter_server }}" != "" ]; then
|
||||||
echo "JUPYTERHUB_SINGLEUSER_APP=jupyterhub.tests.mockserverapp.MockServerApp" >> $GITHUB_ENV
|
echo "JUPYTERHUB_SINGLEUSER_APP=jupyterhub.tests.mockserverapp.MockServerApp" >> $GITHUB_ENV
|
||||||
fi
|
fi
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
# NOTE: actions/setup-node@v1 make use of a cache within the GitHub base
|
# NOTE: actions/setup-node@v3 make use of a cache within the GitHub base
|
||||||
# environment and setup in a fraction of a second.
|
# environment and setup in a fraction of a second.
|
||||||
- name: Install Node v14
|
- name: Install Node v14
|
||||||
uses: actions/setup-node@v1
|
uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: "14"
|
node-version: "14"
|
||||||
- name: Install Node dependencies
|
- name: Install Javascript dependencies
|
||||||
run: |
|
run: |
|
||||||
npm install
|
npm install
|
||||||
npm install -g configurable-http-proxy
|
npm install -g configurable-http-proxy yarn
|
||||||
npm install -g yarn
|
|
||||||
npm list
|
npm list
|
||||||
|
|
||||||
# NOTE: actions/setup-python@v2 make use of a cache within the GitHub base
|
# NOTE: actions/setup-python@v4 make use of a cache within the GitHub base
|
||||||
# environment and setup in a fraction of a second.
|
# environment and setup in a fraction of a second.
|
||||||
- name: Install Python ${{ matrix.python }}
|
- name: Install Python ${{ matrix.python }}
|
||||||
uses: actions/setup-python@v2
|
uses: actions/setup-python@v4
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python }}
|
python-version: "${{ matrix.python }}"
|
||||||
- name: Install Python dependencies
|
- name: Install Python dependencies
|
||||||
run: |
|
run: |
|
||||||
pip install --upgrade pip
|
pip install --upgrade pip
|
||||||
@@ -130,9 +152,9 @@ jobs:
|
|||||||
if [ "${{ matrix.main_dependencies }}" != "" ]; then
|
if [ "${{ matrix.main_dependencies }}" != "" ]; then
|
||||||
pip install git+https://github.com/ipython/traitlets#egg=traitlets --force
|
pip install git+https://github.com/ipython/traitlets#egg=traitlets --force
|
||||||
fi
|
fi
|
||||||
if [ "${{ matrix.jupyter_server }}" != "" ]; then
|
if [ "${{ matrix.legacy_notebook }}" != "" ]; then
|
||||||
pip uninstall notebook --yes
|
pip uninstall jupyter_server --yes
|
||||||
pip install jupyter_server
|
pip install 'notebook<7'
|
||||||
fi
|
fi
|
||||||
if [ "${{ matrix.db }}" == "mysql" ]; then
|
if [ "${{ matrix.db }}" == "mysql" ]; then
|
||||||
pip install mysql-connector-python
|
pip install mysql-connector-python
|
||||||
@@ -168,36 +190,35 @@ jobs:
|
|||||||
if: ${{ matrix.db }}
|
if: ${{ matrix.db }}
|
||||||
run: |
|
run: |
|
||||||
if [ "${{ matrix.db }}" == "mysql" ]; then
|
if [ "${{ matrix.db }}" == "mysql" ]; then
|
||||||
|
if [[ -z "$(which mysql)" ]]; then
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y mysql-client
|
sudo apt-get install -y mysql-client
|
||||||
|
fi
|
||||||
DB=mysql bash ci/docker-db.sh
|
DB=mysql bash ci/docker-db.sh
|
||||||
DB=mysql bash ci/init-db.sh
|
DB=mysql bash ci/init-db.sh
|
||||||
fi
|
fi
|
||||||
if [ "${{ matrix.db }}" == "postgres" ]; then
|
if [ "${{ matrix.db }}" == "postgres" ]; then
|
||||||
|
if [[ -z "$(which psql)" ]]; then
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y postgresql-client
|
sudo apt-get install -y postgresql-client
|
||||||
|
fi
|
||||||
DB=postgres bash ci/docker-db.sh
|
DB=postgres bash ci/docker-db.sh
|
||||||
DB=postgres bash ci/init-db.sh
|
DB=postgres bash ci/init-db.sh
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Run pytest
|
- name: Run pytest
|
||||||
# FIXME: --color=yes explicitly set because:
|
|
||||||
# https://github.com/actions/runner/issues/241
|
|
||||||
run: |
|
run: |
|
||||||
pytest -v --maxfail=2 --color=yes --cov=jupyterhub jupyterhub/tests
|
pytest --maxfail=2 --cov=jupyterhub jupyterhub/tests
|
||||||
- name: Run yarn jest test
|
|
||||||
run: |
|
|
||||||
cd jsx && yarn && yarn test
|
|
||||||
- name: Submit codecov report
|
- name: Submit codecov report
|
||||||
run: |
|
run: |
|
||||||
codecov
|
codecov
|
||||||
|
|
||||||
docker-build:
|
docker-build:
|
||||||
runs-on: ubuntu-20.04
|
runs-on: ubuntu-20.04
|
||||||
timeout-minutes: 10
|
timeout-minutes: 20
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
- name: build images
|
- name: build images
|
||||||
run: |
|
run: |
|
||||||
|
2
.gitignore
vendored
2
.gitignore
vendored
@@ -10,6 +10,7 @@ docs/build
|
|||||||
docs/source/_static/rest-api
|
docs/source/_static/rest-api
|
||||||
docs/source/rbac/scope-table.md
|
docs/source/rbac/scope-table.md
|
||||||
.ipynb_checkpoints
|
.ipynb_checkpoints
|
||||||
|
jsx/build/
|
||||||
# ignore config file at the top-level of the repo
|
# ignore config file at the top-level of the repo
|
||||||
# but not sub-dirs
|
# but not sub-dirs
|
||||||
/jupyterhub_config.py
|
/jupyterhub_config.py
|
||||||
@@ -19,6 +20,7 @@ package-lock.json
|
|||||||
share/jupyterhub/static/components
|
share/jupyterhub/static/components
|
||||||
share/jupyterhub/static/css/style.min.css
|
share/jupyterhub/static/css/style.min.css
|
||||||
share/jupyterhub/static/css/style.min.css.map
|
share/jupyterhub/static/css/style.min.css.map
|
||||||
|
share/jupyterhub/static/js/admin-react.js*
|
||||||
*.egg-info
|
*.egg-info
|
||||||
MANIFEST
|
MANIFEST
|
||||||
.coverage
|
.coverage
|
||||||
|
@@ -1,30 +1,52 @@
|
|||||||
|
# pre-commit is a tool to perform a predefined set of tasks manually and/or
|
||||||
|
# automatically before git commits are made.
|
||||||
|
#
|
||||||
|
# Config reference: https://pre-commit.com/#pre-commit-configyaml---top-level
|
||||||
|
#
|
||||||
|
# Common tasks
|
||||||
|
#
|
||||||
|
# - Run on all files: pre-commit run --all-files
|
||||||
|
# - Register git hooks: pre-commit install --install-hooks
|
||||||
|
#
|
||||||
repos:
|
repos:
|
||||||
|
# Autoformat: Python code, syntax patterns are modernized
|
||||||
- repo: https://github.com/asottile/pyupgrade
|
- repo: https://github.com/asottile/pyupgrade
|
||||||
rev: v2.26.0
|
rev: v2.37.3
|
||||||
hooks:
|
hooks:
|
||||||
- id: pyupgrade
|
- id: pyupgrade
|
||||||
args:
|
args:
|
||||||
- --py36-plus
|
- --py36-plus
|
||||||
- repo: https://github.com/asottile/reorder_python_imports
|
|
||||||
rev: v2.6.0
|
# Autoformat: Python code
|
||||||
|
- repo: https://github.com/pycqa/isort
|
||||||
|
rev: 5.10.1
|
||||||
hooks:
|
hooks:
|
||||||
- id: reorder-python-imports
|
- id: isort
|
||||||
|
|
||||||
|
# Autoformat: Python code
|
||||||
- repo: https://github.com/psf/black
|
- repo: https://github.com/psf/black
|
||||||
rev: 21.8b0
|
rev: 22.6.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: black
|
- id: black
|
||||||
|
|
||||||
|
# Autoformat: markdown, yaml, javascript (see the file .prettierignore)
|
||||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
- repo: https://github.com/pre-commit/mirrors-prettier
|
||||||
rev: v2.4.0
|
rev: v2.7.1
|
||||||
hooks:
|
hooks:
|
||||||
- id: prettier
|
- id: prettier
|
||||||
- repo: https://github.com/PyCQA/flake8
|
|
||||||
rev: "3.9.2"
|
# Autoformat and linting, misc. details
|
||||||
hooks:
|
|
||||||
- id: flake8
|
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
rev: v4.0.1
|
rev: v4.3.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: end-of-file-fixer
|
- id: end-of-file-fixer
|
||||||
|
exclude: share/jupyterhub/static/js/admin-react.js
|
||||||
|
- id: requirements-txt-fixer
|
||||||
- id: check-case-conflict
|
- id: check-case-conflict
|
||||||
- id: check-executables-have-shebangs
|
- id: check-executables-have-shebangs
|
||||||
- id: requirements-txt-fixer
|
|
||||||
|
# Linting: Python code (see the file .flake8)
|
||||||
|
- repo: https://github.com/PyCQA/flake8
|
||||||
|
rev: "5.0.2"
|
||||||
|
hooks:
|
||||||
|
- id: flake8
|
||||||
|
@@ -4,10 +4,12 @@ sphinx:
|
|||||||
configuration: docs/source/conf.py
|
configuration: docs/source/conf.py
|
||||||
|
|
||||||
build:
|
build:
|
||||||
image: latest
|
os: ubuntu-20.04
|
||||||
|
tools:
|
||||||
|
nodejs: "16"
|
||||||
|
python: "3.9"
|
||||||
|
|
||||||
python:
|
python:
|
||||||
version: 3.7
|
|
||||||
install:
|
install:
|
||||||
- method: pip
|
- method: pip
|
||||||
path: .
|
path: .
|
@@ -1,26 +0,0 @@
|
|||||||
# Release checklist
|
|
||||||
|
|
||||||
- [ ] Upgrade Docs prior to Release
|
|
||||||
|
|
||||||
- [ ] Change log
|
|
||||||
- [ ] New features documented
|
|
||||||
- [ ] Update the contributor list - thank you page
|
|
||||||
|
|
||||||
- [ ] Upgrade and test Reference Deployments
|
|
||||||
|
|
||||||
- [ ] Release software
|
|
||||||
|
|
||||||
- [ ] Make sure 0 issues in milestone
|
|
||||||
- [ ] Follow release process steps
|
|
||||||
- [ ] Send builds to PyPI (Warehouse) and Conda Forge
|
|
||||||
|
|
||||||
- [ ] Blog post and/or release note
|
|
||||||
|
|
||||||
- [ ] Notify users of release
|
|
||||||
|
|
||||||
- [ ] Email Jupyter and Jupyter In Education mailing lists
|
|
||||||
- [ ] Tweet (optional)
|
|
||||||
|
|
||||||
- [ ] Increment the version number for the next release
|
|
||||||
|
|
||||||
- [ ] Update roadmap
|
|
133
CONTRIBUTING.md
133
CONTRIBUTING.md
@@ -6,134 +6,9 @@ you can follow the [Jupyter contributor guide](https://jupyter.readthedocs.io/en
|
|||||||
Make sure to also follow [Project Jupyter's Code of Conduct](https://github.com/jupyter/governance/blob/HEAD/conduct/code_of_conduct.md)
|
Make sure to also follow [Project Jupyter's Code of Conduct](https://github.com/jupyter/governance/blob/HEAD/conduct/code_of_conduct.md)
|
||||||
for a friendly and welcoming collaborative environment.
|
for a friendly and welcoming collaborative environment.
|
||||||
|
|
||||||
## Setting up a development environment
|
Please see our documentation on
|
||||||
|
|
||||||
<!--
|
- [Setting up a development install](https://jupyterhub.readthedocs.io/en/latest/contributing/setup.html)
|
||||||
https://jupyterhub.readthedocs.io/en/stable/contributing/setup.html
|
- [Testing JupyterHub and linting code](https://jupyterhub.readthedocs.io/en/latest/contributing/tests.html)
|
||||||
contains a lot of the same information. Should we merge the docs and
|
|
||||||
just have this page link to that one?
|
|
||||||
-->
|
|
||||||
|
|
||||||
JupyterHub requires Python >= 3.5 and nodejs.
|
If you need some help, feel free to ask on [Gitter](https://gitter.im/jupyterhub/jupyterhub) or [Discourse](https://discourse.jupyter.org/).
|
||||||
|
|
||||||
As a Python project, a development install of JupyterHub follows standard practices for the basics (steps 1-2).
|
|
||||||
|
|
||||||
1. clone the repo
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/jupyterhub/jupyterhub
|
|
||||||
```
|
|
||||||
2. do a development install with pip
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd jupyterhub
|
|
||||||
python3 -m pip install --editable .
|
|
||||||
```
|
|
||||||
|
|
||||||
3. install the development requirements,
|
|
||||||
which include things like testing tools
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 -m pip install -r dev-requirements.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
4. install configurable-http-proxy with npm:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install -g configurable-http-proxy
|
|
||||||
```
|
|
||||||
|
|
||||||
5. set up pre-commit hooks for automatic code formatting, etc.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pre-commit install
|
|
||||||
```
|
|
||||||
|
|
||||||
You can also invoke the pre-commit hook manually at any time with
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pre-commit run
|
|
||||||
```
|
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
JupyterHub has adopted automatic code formatting so you shouldn't
|
|
||||||
need to worry too much about your code style.
|
|
||||||
As long as your code is valid,
|
|
||||||
the pre-commit hook should take care of how it should look.
|
|
||||||
You can invoke the pre-commit hook by hand at any time with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pre-commit run
|
|
||||||
```
|
|
||||||
|
|
||||||
which should run any autoformatting on your code
|
|
||||||
and tell you about any errors it couldn't fix automatically.
|
|
||||||
You may also install [black integration](https://github.com/psf/black#editor-integration)
|
|
||||||
into your text editor to format code automatically.
|
|
||||||
|
|
||||||
If you have already committed files before setting up the pre-commit
|
|
||||||
hook with `pre-commit install`, you can fix everything up using
|
|
||||||
`pre-commit run --all-files`. You need to make the fixing commit
|
|
||||||
yourself after that.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
It's a good idea to write tests to exercise any new features,
|
|
||||||
or that trigger any bugs that you have fixed to catch regressions.
|
|
||||||
|
|
||||||
You can run the tests with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pytest -v
|
|
||||||
```
|
|
||||||
|
|
||||||
in the repo directory. If you want to just run certain tests,
|
|
||||||
check out the [pytest docs](https://pytest.readthedocs.io/en/latest/usage.html)
|
|
||||||
for how pytest can be called.
|
|
||||||
For instance, to test only spawner-related things in the REST API:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pytest -v -k spawn jupyterhub/tests/test_api.py
|
|
||||||
```
|
|
||||||
|
|
||||||
The tests live in `jupyterhub/tests` and are organized roughly into:
|
|
||||||
|
|
||||||
1. `test_api.py` tests the REST API
|
|
||||||
2. `test_pages.py` tests loading the HTML pages
|
|
||||||
|
|
||||||
and other collections of tests for different components.
|
|
||||||
When writing a new test, there should usually be a test of
|
|
||||||
similar functionality already written and related tests should
|
|
||||||
be added nearby.
|
|
||||||
|
|
||||||
The fixtures live in `jupyterhub/tests/conftest.py`. There are
|
|
||||||
fixtures that can be used for JupyterHub components, such as:
|
|
||||||
|
|
||||||
- `app`: an instance of JupyterHub with mocked parts
|
|
||||||
- `auth_state_enabled`: enables persisting auth_state (like authentication tokens)
|
|
||||||
- `db`: a sqlite in-memory DB session
|
|
||||||
- `io_loop`: a Tornado event loop
|
|
||||||
- `event_loop`: a new asyncio event loop
|
|
||||||
- `user`: creates a new temporary user
|
|
||||||
- `admin_user`: creates a new temporary admin user
|
|
||||||
- single user servers
|
|
||||||
- `cleanup_after`: allows cleanup of single user servers between tests
|
|
||||||
- mocked service
|
|
||||||
- `MockServiceSpawner`: a spawner that mocks services for testing with a short poll interval
|
|
||||||
- `mockservice`: mocked service with no external service url
|
|
||||||
- `mockservice_url`: mocked service with a url to test external services
|
|
||||||
|
|
||||||
And fixtures to add functionality or spawning behavior:
|
|
||||||
|
|
||||||
- `admin_access`: grants admin access
|
|
||||||
- `no_patience`: sets slow-spawning timeouts to zero
|
|
||||||
- `slow_spawn`: enables the SlowSpawner (a spawner that takes a few seconds to start)
|
|
||||||
- `never_spawn`: enables the NeverSpawner (a spawner that will never start)
|
|
||||||
- `bad_spawn`: enables the BadSpawner (a spawner that fails immediately)
|
|
||||||
- `slow_bad_spawn`: enables the SlowBadSpawner (a spawner that fails after a short delay)
|
|
||||||
|
|
||||||
To read more about fixtures check out the
|
|
||||||
[pytest docs](https://docs.pytest.org/en/latest/fixture.html)
|
|
||||||
for how to use the existing fixtures, and how to create new ones.
|
|
||||||
|
|
||||||
When in doubt, feel free to [ask](https://gitter.im/jupyterhub/jupyterhub).
|
|
||||||
|
@@ -21,7 +21,7 @@
|
|||||||
# your jupyterhub_config.py will be added automatically
|
# your jupyterhub_config.py will be added automatically
|
||||||
# from your docker directory.
|
# from your docker directory.
|
||||||
|
|
||||||
ARG BASE_IMAGE=ubuntu:focal-20200729
|
ARG BASE_IMAGE=ubuntu:22.04
|
||||||
FROM $BASE_IMAGE AS builder
|
FROM $BASE_IMAGE AS builder
|
||||||
|
|
||||||
USER root
|
USER root
|
||||||
@@ -37,6 +37,7 @@ RUN apt-get update \
|
|||||||
python3-pycurl \
|
python3-pycurl \
|
||||||
nodejs \
|
nodejs \
|
||||||
npm \
|
npm \
|
||||||
|
yarn \
|
||||||
&& apt-get clean \
|
&& apt-get clean \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
@@ -8,6 +8,7 @@ include *requirements.txt
|
|||||||
include Dockerfile
|
include Dockerfile
|
||||||
|
|
||||||
graft onbuild
|
graft onbuild
|
||||||
|
graft jsx
|
||||||
graft jupyterhub
|
graft jupyterhub
|
||||||
graft scripts
|
graft scripts
|
||||||
graft share
|
graft share
|
||||||
@@ -18,6 +19,10 @@ graft ci
|
|||||||
graft docs
|
graft docs
|
||||||
prune docs/node_modules
|
prune docs/node_modules
|
||||||
|
|
||||||
|
# Intermediate javascript files
|
||||||
|
prune jsx/node_modules
|
||||||
|
prune jsx/build
|
||||||
|
|
||||||
# prune some large unused files from components
|
# prune some large unused files from components
|
||||||
prune share/jupyterhub/static/components/bootstrap/dist/css
|
prune share/jupyterhub/static/components/bootstrap/dist/css
|
||||||
exclude share/jupyterhub/static/components/bootstrap/dist/fonts/*.svg
|
exclude share/jupyterhub/static/components/bootstrap/dist/fonts/*.svg
|
||||||
|
@@ -56,9 +56,11 @@ Basic principles for operation are:
|
|||||||
servers.
|
servers.
|
||||||
|
|
||||||
JupyterHub also provides a
|
JupyterHub also provides a
|
||||||
[REST API](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/jupyter/jupyterhub/HEAD/docs/rest-api.yml#/default)
|
[REST API][]
|
||||||
for administration of the Hub and its users.
|
for administration of the Hub and its users.
|
||||||
|
|
||||||
|
[rest api]: https://jupyterhub.readthedocs.io/en/latest/reference/rest-api.html
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
### Check prerequisites
|
### Check prerequisites
|
||||||
@@ -115,8 +117,7 @@ To start the Hub server, run the command:
|
|||||||
|
|
||||||
jupyterhub
|
jupyterhub
|
||||||
|
|
||||||
Visit `https://localhost:8000` in your browser, and sign in with your unix
|
Visit `http://localhost:8000` in your browser, and sign in with your system username and password.
|
||||||
PAM credentials.
|
|
||||||
|
|
||||||
_Note_: To allow multiple users to sign in to the server, you will need to
|
_Note_: To allow multiple users to sign in to the server, you will need to
|
||||||
run the `jupyterhub` command as a _privileged user_, such as root.
|
run the `jupyterhub` command as a _privileged user_, such as root.
|
||||||
@@ -239,7 +240,7 @@ You can also talk with us on our JupyterHub [Gitter](https://gitter.im/jupyterhu
|
|||||||
- [Reporting Issues](https://github.com/jupyterhub/jupyterhub/issues)
|
- [Reporting Issues](https://github.com/jupyterhub/jupyterhub/issues)
|
||||||
- [JupyterHub tutorial](https://github.com/jupyterhub/jupyterhub-tutorial)
|
- [JupyterHub tutorial](https://github.com/jupyterhub/jupyterhub-tutorial)
|
||||||
- [Documentation for JupyterHub](https://jupyterhub.readthedocs.io/en/latest/) | [PDF (latest)](https://media.readthedocs.org/pdf/jupyterhub/latest/jupyterhub.pdf) | [PDF (stable)](https://media.readthedocs.org/pdf/jupyterhub/stable/jupyterhub.pdf)
|
- [Documentation for JupyterHub](https://jupyterhub.readthedocs.io/en/latest/) | [PDF (latest)](https://media.readthedocs.org/pdf/jupyterhub/latest/jupyterhub.pdf) | [PDF (stable)](https://media.readthedocs.org/pdf/jupyterhub/stable/jupyterhub.pdf)
|
||||||
- [Documentation for JupyterHub's REST API](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/jupyter/jupyterhub/HEAD/docs/rest-api.yml#/default)
|
- [Documentation for JupyterHub's REST API][rest api]
|
||||||
- [Documentation for Project Jupyter](http://jupyter.readthedocs.io/en/latest/index.html) | [PDF](https://media.readthedocs.org/pdf/jupyter/latest/jupyter.pdf)
|
- [Documentation for Project Jupyter](http://jupyter.readthedocs.io/en/latest/index.html) | [PDF](https://media.readthedocs.org/pdf/jupyter/latest/jupyter.pdf)
|
||||||
- [Project Jupyter website](https://jupyter.org)
|
- [Project Jupyter website](https://jupyter.org)
|
||||||
- [Project Jupyter community](https://jupyter.org/community)
|
- [Project Jupyter community](https://jupyter.org/community)
|
||||||
|
50
RELEASE.md
Normal file
50
RELEASE.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# How to make a release
|
||||||
|
|
||||||
|
`jupyterhub` is a package [available on
|
||||||
|
PyPI](https://pypi.org/project/jupyterhub/) and
|
||||||
|
[conda-forge](https://conda-forge.org/).
|
||||||
|
These are instructions on how to make a release on PyPI.
|
||||||
|
The PyPI release is done automatically by CI when a tag is pushed.
|
||||||
|
|
||||||
|
For you to follow along according to these instructions, you need:
|
||||||
|
|
||||||
|
- To have push rights to the [jupyterhub GitHub
|
||||||
|
repository](https://github.com/jupyterhub/jupyterhub).
|
||||||
|
|
||||||
|
## Steps to make a release
|
||||||
|
|
||||||
|
1. Checkout main and make sure it is up to date.
|
||||||
|
|
||||||
|
```shell
|
||||||
|
ORIGIN=${ORIGIN:-origin} # set to the canonical remote, e.g. 'upstream' if 'origin' is not the official repo
|
||||||
|
git checkout main
|
||||||
|
git fetch $ORIGIN main
|
||||||
|
git reset --hard $ORIGIN/main
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Make sure `docs/source/changelog.md` is up-to-date.
|
||||||
|
[github-activity][] can help with this.
|
||||||
|
|
||||||
|
1. Update the version with `tbump`.
|
||||||
|
You can see what will happen without making any changes with `tbump --dry-run ${VERSION}`
|
||||||
|
|
||||||
|
```shell
|
||||||
|
tbump ${VERSION}
|
||||||
|
```
|
||||||
|
|
||||||
|
This will tag and publish a release,
|
||||||
|
which will be finished on CI.
|
||||||
|
|
||||||
|
1. Reset the version back to dev, e.g. `2.1.0.dev` after releasing `2.0.0`
|
||||||
|
|
||||||
|
```shell
|
||||||
|
tbump --no-tag ${NEXT_VERSION}.dev
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Following the release to PyPI, an automated PR should arrive to
|
||||||
|
[conda-forge/jupyterhub-feedstock][],
|
||||||
|
check for the tests to succeed on this PR and then merge it to successfully
|
||||||
|
update the package for `conda` on the conda-forge channel.
|
||||||
|
|
||||||
|
[github-activity]: https://github.com/choldgraf/github-activity
|
||||||
|
[conda-forge/jupyterhub-feedstock]: https://github.com/conda-forge/jupyterhub-feedstock
|
5
SECURITY.md
Normal file
5
SECURITY.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Reporting a Vulnerability
|
||||||
|
|
||||||
|
If you believe you’ve found a security vulnerability in a Jupyter
|
||||||
|
project, please report it to security@ipython.org. If you prefer to
|
||||||
|
encrypt your security reports, you can use [this PGP public key](https://jupyter-notebook.readthedocs.io/en/stable/_downloads/1d303a645f2505a8fd283826fafc9908/ipython_security.asc).
|
20
ci/check_installed_data.py
Executable file
20
ci/check_installed_data.py
Executable file
@@ -0,0 +1,20 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
# Check that installed package contains everything we expect
|
||||||
|
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from jupyterhub._data import DATA_FILES_PATH
|
||||||
|
|
||||||
|
print("Checking jupyterhub._data")
|
||||||
|
print(f"DATA_FILES_PATH={DATA_FILES_PATH}")
|
||||||
|
assert os.path.exists(DATA_FILES_PATH), DATA_FILES_PATH
|
||||||
|
for subpath in (
|
||||||
|
"templates/page.html",
|
||||||
|
"static/css/style.min.css",
|
||||||
|
"static/components/jquery/dist/jquery.js",
|
||||||
|
"static/js/admin-react.js",
|
||||||
|
):
|
||||||
|
path = os.path.join(DATA_FILES_PATH, subpath)
|
||||||
|
assert os.path.exists(path), path
|
||||||
|
print("OK")
|
28
ci/check_sdist.py
Executable file
28
ci/check_sdist.py
Executable file
@@ -0,0 +1,28 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
# Check that sdist contains everything we expect
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import tarfile
|
||||||
|
from tarfile import TarFile
|
||||||
|
|
||||||
|
expected_files = [
|
||||||
|
"docs/requirements.txt",
|
||||||
|
"jsx/package.json",
|
||||||
|
"package.json",
|
||||||
|
"README.md",
|
||||||
|
]
|
||||||
|
|
||||||
|
assert len(sys.argv) == 2, "Expected one file"
|
||||||
|
print(f"Checking {sys.argv[1]}")
|
||||||
|
|
||||||
|
tar = tarfile.open(name=sys.argv[1], mode="r:gz")
|
||||||
|
try:
|
||||||
|
# Remove leading jupyterhub-VERSION/
|
||||||
|
filelist = {f.partition('/')[2] for f in tar.getnames()}
|
||||||
|
finally:
|
||||||
|
tar.close()
|
||||||
|
|
||||||
|
for e in expected_files:
|
||||||
|
assert e in filelist, f"{e} not found"
|
||||||
|
|
||||||
|
print("OK")
|
@@ -20,7 +20,7 @@ fi
|
|||||||
|
|
||||||
# Configure a set of databases in the database server for upgrade tests
|
# Configure a set of databases in the database server for upgrade tests
|
||||||
set -x
|
set -x
|
||||||
for SUFFIX in '' _upgrade_100 _upgrade_122 _upgrade_130; do
|
for SUFFIX in '' _upgrade_100 _upgrade_122 _upgrade_130 _upgrade_150 _upgrade_211; do
|
||||||
$SQL_CLIENT "DROP DATABASE jupyterhub${SUFFIX};" 2>/dev/null || true
|
$SQL_CLIENT "DROP DATABASE jupyterhub${SUFFIX};" 2>/dev/null || true
|
||||||
$SQL_CLIENT "CREATE DATABASE jupyterhub${SUFFIX} ${EXTRA_CREATE_DATABASE_ARGS:-};"
|
$SQL_CLIENT "CREATE DATABASE jupyterhub${SUFFIX} ${EXTRA_CREATE_DATABASE_ARGS:-};"
|
||||||
done
|
done
|
||||||
|
@@ -7,13 +7,18 @@ codecov
|
|||||||
coverage
|
coverage
|
||||||
cryptography
|
cryptography
|
||||||
html5lib # needed for beautifulsoup
|
html5lib # needed for beautifulsoup
|
||||||
|
jupyterlab >=3
|
||||||
mock
|
mock
|
||||||
notebook
|
# nbclassic provides the '/tree/' handler, which we use in tests
|
||||||
|
# it is a transitive dependency via jupyterlab,
|
||||||
|
# but depend on it directly
|
||||||
|
nbclassic
|
||||||
pre-commit
|
pre-commit
|
||||||
pytest>=3.3
|
pytest>=3.3
|
||||||
pytest-asyncio
|
pytest-asyncio>=0.17
|
||||||
pytest-cov
|
pytest-cov
|
||||||
requests-mock
|
requests-mock
|
||||||
|
tbump
|
||||||
# blacklist urllib3 releases affected by https://github.com/urllib3/urllib3/issues/1683
|
# blacklist urllib3 releases affected by https://github.com/urllib3/urllib3/issues/1683
|
||||||
# I *think* this should only affect testing, not production
|
# I *think* this should only affect testing, not production
|
||||||
urllib3!=1.25.4,!=1.25.5
|
urllib3!=1.25.4,!=1.25.5
|
||||||
|
@@ -53,14 +53,6 @@ help:
|
|||||||
clean:
|
clean:
|
||||||
rm -rf $(BUILDDIR)/*
|
rm -rf $(BUILDDIR)/*
|
||||||
|
|
||||||
node_modules: package.json
|
|
||||||
npm install && touch node_modules
|
|
||||||
|
|
||||||
rest-api: source/_static/rest-api/index.html
|
|
||||||
|
|
||||||
source/_static/rest-api/index.html: rest-api.yml node_modules
|
|
||||||
npm run rest-api
|
|
||||||
|
|
||||||
metrics: source/reference/metrics.rst
|
metrics: source/reference/metrics.rst
|
||||||
|
|
||||||
source/reference/metrics.rst: generate-metrics.py
|
source/reference/metrics.rst: generate-metrics.py
|
||||||
@@ -71,7 +63,7 @@ scopes: source/rbac/scope-table.md
|
|||||||
source/rbac/scope-table.md: source/rbac/generate-scope-table.py
|
source/rbac/scope-table.md: source/rbac/generate-scope-table.py
|
||||||
python3 source/rbac/generate-scope-table.py
|
python3 source/rbac/generate-scope-table.py
|
||||||
|
|
||||||
html: rest-api metrics scopes
|
html: metrics scopes
|
||||||
$(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
|
$(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
|
||||||
@echo
|
@echo
|
||||||
@echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
|
@echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
|
||||||
|
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "jupyterhub-docs-build",
|
|
||||||
"version": "0.8.0",
|
|
||||||
"description": "build JupyterHub swagger docs",
|
|
||||||
"scripts": {
|
|
||||||
"rest-api": "bootprint openapi ./rest-api.yml source/_static/rest-api"
|
|
||||||
},
|
|
||||||
"author": "",
|
|
||||||
"license": "BSD-3-Clause",
|
|
||||||
"devDependencies": {
|
|
||||||
"bootprint": "^1.0.0",
|
|
||||||
"bootprint-openapi": "^1.0.0"
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,12 +1,12 @@
|
|||||||
-r ../requirements.txt
|
-r ../requirements.txt
|
||||||
|
|
||||||
alabaster_jupyterhub
|
alabaster_jupyterhub
|
||||||
# Temporary fix of #3021. Revert back to released autodoc-traits when
|
autodoc-traits
|
||||||
# 0.1.0 released.
|
|
||||||
https://github.com/jupyterhub/autodoc-traits/archive/d22282c1c18c6865436e06d8b329c06fe12a07f8.zip
|
|
||||||
myst-parser
|
myst-parser
|
||||||
|
pre-commit
|
||||||
pydata-sphinx-theme
|
pydata-sphinx-theme
|
||||||
pytablewriter>=0.56
|
pytablewriter>=0.56
|
||||||
|
ruamel.yaml
|
||||||
sphinx>=1.7
|
sphinx>=1.7
|
||||||
sphinx-copybutton
|
sphinx-copybutton
|
||||||
sphinx-jsonschema
|
sphinx-jsonschema
|
||||||
|
1196
docs/rest-api.yml
1196
docs/rest-api.yml
File diff suppressed because it is too large
Load Diff
@@ -2,3 +2,9 @@
|
|||||||
.navbar-brand {
|
.navbar-brand {
|
||||||
height: 4rem !important;
|
height: 4rem !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* hide redundant funky-formatted swagger-ui version */
|
||||||
|
|
||||||
|
.swagger-ui .info .title small {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
1469
docs/source/_static/rest-api.yml
Normal file
1469
docs/source/_static/rest-api.yml
Normal file
File diff suppressed because it is too large
Load Diff
72
docs/source/admin/log-messages.md
Normal file
72
docs/source/admin/log-messages.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# Interpreting common log messages
|
||||||
|
|
||||||
|
When debugging errors and outages, looking at the logs emitted by
|
||||||
|
JupyterHub is very helpful. This document tries to document some common
|
||||||
|
log messages, and what they mean.
|
||||||
|
|
||||||
|
## Failing suspected API request to not-running server
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
Your logs might be littered with lines that might look slightly scary
|
||||||
|
|
||||||
|
```
|
||||||
|
[W 2022-03-10 17:25:19.774 JupyterHub base:1349] Failing suspected API request to not-running server: /hub/user/<user-name>/api/metrics/v1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Most likely cause
|
||||||
|
|
||||||
|
This likely means is that the user's server has stopped running but they
|
||||||
|
still have a browser tab open. For example, you might have 3 tabs open, and shut
|
||||||
|
your server down via one. Or you closed your laptop, your server was
|
||||||
|
culled for inactivity, and then you reopen your laptop again! The
|
||||||
|
client side code (JupyterLab, Classic Notebook, etc) does not know
|
||||||
|
yet that the server is dead, and continues to make some API requests.
|
||||||
|
JupyterHub's architecture means that the proxy routes all requests that
|
||||||
|
don't go to a running user server to the hub process itself. The hub
|
||||||
|
process then explicitly returns a failure response, so the client knows
|
||||||
|
that the server is not running anymore. This is used by JupyterLab to
|
||||||
|
tell you your server is not running anymore, and offer you the option
|
||||||
|
to let you restart it.
|
||||||
|
|
||||||
|
Most commonly, you'll see this in reference to the `/api/metrics/v1`
|
||||||
|
URL, used by [jupyter-resource-usage](https://github.com/jupyter-server/jupyter-resource-usage).
|
||||||
|
|
||||||
|
### Actions you can take
|
||||||
|
|
||||||
|
This log message is benign, and there is usually no action for you to take.
|
||||||
|
|
||||||
|
## JupyterHub Singleuser Version mismatch
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
```
|
||||||
|
jupyterhub version 1.5.0 != jupyterhub-singleuser version 1.3.0. This could cause failure to authenticate and result in redirect loops!
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cause
|
||||||
|
|
||||||
|
JupyterHub requires the `jupyterhub` python package installed inside the image or
|
||||||
|
environment the user server starts in. This message indicates that the version of
|
||||||
|
the `jupyterhub` package installed inside the user image or environment is not
|
||||||
|
the same version as the JupyterHub server itself. This is not necessarily always a
|
||||||
|
problem - some version drift is mostly acceptable, and the only two known cases of
|
||||||
|
breakage are across the 0.7 and 2.0 version releases. In those cases, issues pop
|
||||||
|
up immediately after upgrading your version of JupyterHub, so **always check the JupyterHub
|
||||||
|
changelog before upgrading!**. The primary problems this _could_ cause are:
|
||||||
|
|
||||||
|
1. Infinite redirect loops after the user server starts
|
||||||
|
2. Missing expected environment variables in the user server once it starts
|
||||||
|
3. Failure for the started user server to authenticate with the JupyterHub server -
|
||||||
|
note that this is _not_ the same as _user authentication_ failing!
|
||||||
|
|
||||||
|
However, for the most part, unless you are seeing these specific issues, the log
|
||||||
|
message should be counted as a warning to get the `jupyterhub` package versions
|
||||||
|
aligned, rather than as an indicator of an existing problem.
|
||||||
|
|
||||||
|
### Actions you can take
|
||||||
|
|
||||||
|
Upgrade the version of the `jupyterhub` package in your user environment or image
|
||||||
|
so it matches the version of JupyterHub running your JupyterHub server! If you
|
||||||
|
are using the [zero-to-jupyterhub](https://z2jh.jupyter.org) helm chart, you can find the appropriate
|
||||||
|
version of the `jupyterhub` package to install in your user image [here](https://jupyterhub.github.io/helm-chart/)
|
@@ -1,5 +1,3 @@
|
|||||||
.. _admin/upgrading:
|
|
||||||
|
|
||||||
====================
|
====================
|
||||||
Upgrading JupyterHub
|
Upgrading JupyterHub
|
||||||
====================
|
====================
|
||||||
|
@@ -17,11 +17,6 @@ information on:
|
|||||||
- making an API request programmatically using the requests library
|
- making an API request programmatically using the requests library
|
||||||
- learning more about JupyterHub's API
|
- learning more about JupyterHub's API
|
||||||
|
|
||||||
The same JupyterHub API spec, as found here, is available in an interactive form
|
|
||||||
`here (on swagger's petstore) <https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/jupyterhub/jupyterhub/HEAD/docs/rest-api.yml#!/default>`__.
|
|
||||||
The `OpenAPI Initiative`_ (fka Swagger™) is a project used to describe
|
|
||||||
and document RESTful APIs.
|
|
||||||
|
|
||||||
JupyterHub API Reference:
|
JupyterHub API Reference:
|
||||||
|
|
||||||
.. toctree::
|
.. toctree::
|
||||||
|
File diff suppressed because one or more lines are too long
@@ -21,6 +21,7 @@ extensions = [
|
|||||||
'myst_parser',
|
'myst_parser',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
myst_heading_anchors = 2
|
||||||
myst_enable_extensions = [
|
myst_enable_extensions = [
|
||||||
'colon_fence',
|
'colon_fence',
|
||||||
'deflist',
|
'deflist',
|
||||||
@@ -47,7 +48,7 @@ version = '%i.%i' % jupyterhub.version_info[:2]
|
|||||||
# The full version, including alpha/beta/rc tags.
|
# The full version, including alpha/beta/rc tags.
|
||||||
release = jupyterhub.__version__
|
release = jupyterhub.__version__
|
||||||
|
|
||||||
language = None
|
language = "en"
|
||||||
exclude_patterns = []
|
exclude_patterns = []
|
||||||
pygments_style = 'sphinx'
|
pygments_style = 'sphinx'
|
||||||
todo_include_todos = False
|
todo_include_todos = False
|
||||||
@@ -55,13 +56,15 @@ todo_include_todos = False
|
|||||||
# Set the default role so we can use `foo` instead of ``foo``
|
# Set the default role so we can use `foo` instead of ``foo``
|
||||||
default_role = 'literal'
|
default_role = 'literal'
|
||||||
|
|
||||||
# -- Config -------------------------------------------------------------
|
|
||||||
from jupyterhub.app import JupyterHub
|
|
||||||
from docutils import nodes
|
|
||||||
from sphinx.directives.other import SphinxDirective
|
|
||||||
from contextlib import redirect_stdout
|
from contextlib import redirect_stdout
|
||||||
from io import StringIO
|
from io import StringIO
|
||||||
|
|
||||||
|
from docutils import nodes
|
||||||
|
from sphinx.directives.other import SphinxDirective
|
||||||
|
|
||||||
|
# -- Config -------------------------------------------------------------
|
||||||
|
from jupyterhub.app import JupyterHub
|
||||||
|
|
||||||
# create a temp instance of JupyterHub just to get the output of the generate-config
|
# create a temp instance of JupyterHub just to get the output of the generate-config
|
||||||
# and help --all commands.
|
# and help --all commands.
|
||||||
jupyterhub_app = JupyterHub()
|
jupyterhub_app = JupyterHub()
|
||||||
@@ -130,6 +133,30 @@ html_static_path = ['_static']
|
|||||||
|
|
||||||
htmlhelp_basename = 'JupyterHubdoc'
|
htmlhelp_basename = 'JupyterHubdoc'
|
||||||
|
|
||||||
|
html_theme_options = {
|
||||||
|
"icon_links": [
|
||||||
|
{
|
||||||
|
"name": "GitHub",
|
||||||
|
"url": "https://github.com/jupyterhub/jupyterhub",
|
||||||
|
"icon": "fab fa-github-square",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Discourse",
|
||||||
|
"url": "https://discourse.jupyter.org/c/jupyterhub/10",
|
||||||
|
"icon": "fab fa-discourse",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"use_edit_page_button": True,
|
||||||
|
"navbar_align": "left",
|
||||||
|
}
|
||||||
|
|
||||||
|
html_context = {
|
||||||
|
"github_user": "jupyterhub",
|
||||||
|
"github_repo": "jupyterhub",
|
||||||
|
"github_version": "main",
|
||||||
|
"doc_path": "docs",
|
||||||
|
}
|
||||||
|
|
||||||
# -- Options for LaTeX output ---------------------------------------------
|
# -- Options for LaTeX output ---------------------------------------------
|
||||||
|
|
||||||
latex_elements = {
|
latex_elements = {
|
||||||
@@ -205,7 +232,10 @@ epub_exclude_files = ['search.html']
|
|||||||
|
|
||||||
# -- Intersphinx ----------------------------------------------------------
|
# -- Intersphinx ----------------------------------------------------------
|
||||||
|
|
||||||
intersphinx_mapping = {'https://docs.python.org/3/': None}
|
intersphinx_mapping = {
|
||||||
|
'python': ('https://docs.python.org/3/', None),
|
||||||
|
'tornado': ('https://www.tornadoweb.org/en/stable/', None),
|
||||||
|
}
|
||||||
|
|
||||||
# -- Read The Docs --------------------------------------------------------
|
# -- Read The Docs --------------------------------------------------------
|
||||||
|
|
||||||
@@ -215,7 +245,7 @@ if on_rtd:
|
|||||||
# build both metrics and rest-api, since RTD doesn't run make
|
# build both metrics and rest-api, since RTD doesn't run make
|
||||||
from subprocess import check_call as sh
|
from subprocess import check_call as sh
|
||||||
|
|
||||||
sh(['make', 'metrics', 'rest-api', 'scopes'], cwd=docs)
|
sh(['make', 'metrics', 'scopes'], cwd=docs)
|
||||||
|
|
||||||
# -- Spell checking -------------------------------------------------------
|
# -- Spell checking -------------------------------------------------------
|
||||||
|
|
||||||
|
@@ -16,7 +16,7 @@ Install Python
|
|||||||
--------------
|
--------------
|
||||||
|
|
||||||
JupyterHub is written in the `Python <https://python.org>`_ programming language, and
|
JupyterHub is written in the `Python <https://python.org>`_ programming language, and
|
||||||
requires you have at least version 3.5 installed locally. If you haven’t
|
requires you have at least version 3.6 installed locally. If you haven’t
|
||||||
installed Python before, the recommended way to install it is to use
|
installed Python before, the recommended way to install it is to use
|
||||||
`miniconda <https://conda.io/miniconda.html>`_. Remember to get the ‘Python 3’ version,
|
`miniconda <https://conda.io/miniconda.html>`_. Remember to get the ‘Python 3’ version,
|
||||||
and **not** the ‘Python 2’ version!
|
and **not** the ‘Python 2’ version!
|
||||||
@@ -24,11 +24,10 @@ and **not** the ‘Python 2’ version!
|
|||||||
Install nodejs
|
Install nodejs
|
||||||
--------------
|
--------------
|
||||||
|
|
||||||
``configurable-http-proxy``, the default proxy implementation for
|
`NodeJS 12+ <https://nodejs.org/en/>`_ is required for building some JavaScript components.
|
||||||
JupyterHub, is written in Javascript to run on `NodeJS
|
``configurable-http-proxy``, the default proxy implementation for JupyterHub, is written in Javascript.
|
||||||
<https://nodejs.org/en/>`_. If you have not installed nodejs before, we
|
If you have not installed nodejs before, we recommend installing it in the ``miniconda`` environment you set up for Python.
|
||||||
recommend installing it in the ``miniconda`` environment you set up for
|
You can do so with ``conda install nodejs``.
|
||||||
Python. You can do so with ``conda install nodejs``.
|
|
||||||
|
|
||||||
Install git
|
Install git
|
||||||
-----------
|
-----------
|
||||||
@@ -46,7 +45,7 @@ their effects quickly. You need to do a developer install to make that
|
|||||||
happen.
|
happen.
|
||||||
|
|
||||||
.. note:: This guide does not attempt to dictate *how* development
|
.. note:: This guide does not attempt to dictate *how* development
|
||||||
environements should be isolated since that is a personal preference and can
|
environments should be isolated since that is a personal preference and can
|
||||||
be achieved in many ways, for example `tox`, `conda`, `docker`, etc. See this
|
be achieved in many ways, for example `tox`, `conda`, `docker`, etc. See this
|
||||||
`forum thread <https://discourse.jupyter.org/t/thoughts-on-using-tox/3497>`_ for
|
`forum thread <https://discourse.jupyter.org/t/thoughts-on-using-tox/3497>`_ for
|
||||||
a more detailed discussion.
|
a more detailed discussion.
|
||||||
@@ -66,7 +65,7 @@ happen.
|
|||||||
|
|
||||||
python -V
|
python -V
|
||||||
|
|
||||||
This should return a version number greater than or equal to 3.5.
|
This should return a version number greater than or equal to 3.6.
|
||||||
|
|
||||||
.. code:: bash
|
.. code:: bash
|
||||||
|
|
||||||
@@ -74,12 +73,11 @@ happen.
|
|||||||
|
|
||||||
This should return a version number greater than or equal to 5.0.
|
This should return a version number greater than or equal to 5.0.
|
||||||
|
|
||||||
3. Install ``configurable-http-proxy``. This is required to run
|
3. Install ``configurable-http-proxy`` (required to run and test the default JupyterHub configuration) and ``yarn`` (required to build some components):
|
||||||
JupyterHub.
|
|
||||||
|
|
||||||
.. code:: bash
|
.. code:: bash
|
||||||
|
|
||||||
npm install -g configurable-http-proxy
|
npm install -g configurable-http-proxy yarn
|
||||||
|
|
||||||
If you get an error that says ``Error: EACCES: permission denied``,
|
If you get an error that says ``Error: EACCES: permission denied``,
|
||||||
you might need to prefix the command with ``sudo``. If you do not
|
you might need to prefix the command with ``sudo``. If you do not
|
||||||
@@ -87,11 +85,17 @@ happen.
|
|||||||
|
|
||||||
.. code:: bash
|
.. code:: bash
|
||||||
|
|
||||||
npm install configurable-http-proxy
|
npm install configurable-http-proxy yarn
|
||||||
export PATH=$PATH:$(pwd)/node_modules/.bin
|
export PATH=$PATH:$(pwd)/node_modules/.bin
|
||||||
|
|
||||||
The second line needs to be run every time you open a new terminal.
|
The second line needs to be run every time you open a new terminal.
|
||||||
|
|
||||||
|
If you are using conda you can instead run:
|
||||||
|
|
||||||
|
.. code:: bash
|
||||||
|
|
||||||
|
conda install configurable-http-proxy yarn
|
||||||
|
|
||||||
4. Install the python packages required for JupyterHub development.
|
4. Install the python packages required for JupyterHub development.
|
||||||
|
|
||||||
.. code:: bash
|
.. code:: bash
|
||||||
@@ -186,3 +190,4 @@ development updates, with:
|
|||||||
|
|
||||||
python3 setup.py js # fetch updated client-side js
|
python3 setup.py js # fetch updated client-side js
|
||||||
python3 setup.py css # recompile CSS from LESS sources
|
python3 setup.py css # recompile CSS from LESS sources
|
||||||
|
python3 setup.py jsx # build React admin app
|
||||||
|
@@ -1,8 +1,8 @@
|
|||||||
.. _contributing/tests:
|
.. _contributing/tests:
|
||||||
|
|
||||||
==================
|
===================================
|
||||||
Testing JupyterHub
|
Testing JupyterHub and linting code
|
||||||
==================
|
===================================
|
||||||
|
|
||||||
Unit test help validate that JupyterHub works the way we think it does,
|
Unit test help validate that JupyterHub works the way we think it does,
|
||||||
and continues to do so when changes occur. They also help communicate
|
and continues to do so when changes occur. They also help communicate
|
||||||
@@ -57,6 +57,50 @@ Running the tests
|
|||||||
|
|
||||||
pytest -v jupyterhub/tests/test_api.py::test_shutdown
|
pytest -v jupyterhub/tests/test_api.py::test_shutdown
|
||||||
|
|
||||||
|
See the `pytest usage documentation <https://pytest.readthedocs.io/en/latest/usage.html>`_ for more details.
|
||||||
|
|
||||||
|
Test organisation
|
||||||
|
=================
|
||||||
|
|
||||||
|
The tests live in ``jupyterhub/tests`` and are organized roughly into:
|
||||||
|
|
||||||
|
#. ``test_api.py`` tests the REST API
|
||||||
|
#. ``test_pages.py`` tests loading the HTML pages
|
||||||
|
|
||||||
|
and other collections of tests for different components.
|
||||||
|
When writing a new test, there should usually be a test of
|
||||||
|
similar functionality already written and related tests should
|
||||||
|
be added nearby.
|
||||||
|
|
||||||
|
The fixtures live in ``jupyterhub/tests/conftest.py``. There are
|
||||||
|
fixtures that can be used for JupyterHub components, such as:
|
||||||
|
|
||||||
|
- ``app``: an instance of JupyterHub with mocked parts
|
||||||
|
- ``auth_state_enabled``: enables persisting auth_state (like authentication tokens)
|
||||||
|
- ``db``: a sqlite in-memory DB session
|
||||||
|
- ``io_loop```: a Tornado event loop
|
||||||
|
- ``event_loop``: a new asyncio event loop
|
||||||
|
- ``user``: creates a new temporary user
|
||||||
|
- ``admin_user``: creates a new temporary admin user
|
||||||
|
- single user servers
|
||||||
|
- ``cleanup_after``: allows cleanup of single user servers between tests
|
||||||
|
- mocked service
|
||||||
|
- ``MockServiceSpawner``: a spawner that mocks services for testing with a short poll interval
|
||||||
|
- ``mockservice```: mocked service with no external service url
|
||||||
|
- ``mockservice_url``: mocked service with a url to test external services
|
||||||
|
|
||||||
|
And fixtures to add functionality or spawning behavior:
|
||||||
|
|
||||||
|
- ``admin_access``: grants admin access
|
||||||
|
- ``no_patience```: sets slow-spawning timeouts to zero
|
||||||
|
- ``slow_spawn``: enables the SlowSpawner (a spawner that takes a few seconds to start)
|
||||||
|
- ``never_spawn``: enables the NeverSpawner (a spawner that will never start)
|
||||||
|
- ``bad_spawn``: enables the BadSpawner (a spawner that fails immediately)
|
||||||
|
- ``slow_bad_spawn``: enables the SlowBadSpawner (a spawner that fails after a short delay)
|
||||||
|
|
||||||
|
See the `pytest fixtures documentation <https://pytest.readthedocs.io/en/latest/fixture.html>`_
|
||||||
|
for how to use the existing fixtures, and how to create new ones.
|
||||||
|
|
||||||
|
|
||||||
Troubleshooting Test Failures
|
Troubleshooting Test Failures
|
||||||
=============================
|
=============================
|
||||||
@@ -66,3 +110,27 @@ All the tests are failing
|
|||||||
|
|
||||||
Make sure you have completed all the steps in :ref:`contributing/setup` successfully, and
|
Make sure you have completed all the steps in :ref:`contributing/setup` successfully, and
|
||||||
can launch ``jupyterhub`` from the terminal.
|
can launch ``jupyterhub`` from the terminal.
|
||||||
|
|
||||||
|
|
||||||
|
Code formatting and linting
|
||||||
|
===========================
|
||||||
|
|
||||||
|
JupyterHub has adopted automatic code formatting and linting.
|
||||||
|
As long as your code is valid, the pre-commit hook should take care of how it should look.
|
||||||
|
You can invoke the pre-commit hook by hand at any time with:
|
||||||
|
|
||||||
|
.. code:: bash
|
||||||
|
|
||||||
|
pre-commit run
|
||||||
|
|
||||||
|
which should run any autoformatting on your code and tell you about any errors it couldn't fix automatically.
|
||||||
|
You may also install `black integration <https://github.com/psf/black#editor-integration>`_
|
||||||
|
into your text editor to format code automatically.
|
||||||
|
|
||||||
|
If you have already committed files before running pre-commit you can fix everything using:
|
||||||
|
|
||||||
|
.. code:: bash
|
||||||
|
|
||||||
|
pre-commit run --all-files
|
||||||
|
|
||||||
|
And committing the changes.
|
||||||
|
@@ -16,6 +16,10 @@ c.Authenticator.allowed_users = {'mal', 'zoe', 'inara', 'kaylee'}
|
|||||||
Users in the `allowed_users` set are added to the Hub database when the Hub is
|
Users in the `allowed_users` set are added to the Hub database when the Hub is
|
||||||
started.
|
started.
|
||||||
|
|
||||||
|
```{warning}
|
||||||
|
If this configuration value is not set, then **all authenticated users will be allowed into your hub**.
|
||||||
|
```
|
||||||
|
|
||||||
## Configure admins (`admin_users`)
|
## Configure admins (`admin_users`)
|
||||||
|
|
||||||
```{note}
|
```{note}
|
||||||
|
@@ -183,12 +183,6 @@ itself, ``jupyterhub_config.py``, as a binary string:
|
|||||||
|
|
||||||
c.JupyterHub.cookie_secret = bytes.fromhex('64 CHAR HEX STRING')
|
c.JupyterHub.cookie_secret = bytes.fromhex('64 CHAR HEX STRING')
|
||||||
|
|
||||||
|
|
||||||
.. important::
|
|
||||||
|
|
||||||
If the cookie secret value changes for the Hub, all single-user notebook
|
|
||||||
servers must also be restarted.
|
|
||||||
|
|
||||||
.. _cookies:
|
.. _cookies:
|
||||||
|
|
||||||
Cookies used by JupyterHub authentication
|
Cookies used by JupyterHub authentication
|
||||||
|
BIN
docs/source/images/binder-404.png
Normal file
BIN
docs/source/images/binder-404.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 160 KiB |
BIN
docs/source/images/binderhub-form.png
Normal file
BIN
docs/source/images/binderhub-form.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 138 KiB |
BIN
docs/source/images/chp-404.png
Normal file
BIN
docs/source/images/chp-404.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 38 KiB |
BIN
docs/source/images/dropdown-details-3.0.png
Normal file
BIN
docs/source/images/dropdown-details-3.0.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.1 MiB |
BIN
docs/source/images/server-not-running.png
Normal file
BIN
docs/source/images/server-not-running.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 66 KiB |
@@ -10,4 +10,5 @@ well as other information relevant to running your own JupyterHub over time.
|
|||||||
|
|
||||||
troubleshooting
|
troubleshooting
|
||||||
admin/upgrading
|
admin/upgrading
|
||||||
|
admin/log-messages
|
||||||
changelog
|
changelog
|
||||||
|
@@ -43,7 +43,7 @@ JupyterHub performs the following functions:
|
|||||||
notebook servers
|
notebook servers
|
||||||
|
|
||||||
For convenient administration of the Hub, its users, and services,
|
For convenient administration of the Hub, its users, and services,
|
||||||
JupyterHub also provides a `REST API`_.
|
JupyterHub also provides a :doc:`REST API <reference/rest-api>`.
|
||||||
|
|
||||||
The JupyterHub team and Project Jupyter value our community, and JupyterHub
|
The JupyterHub team and Project Jupyter value our community, and JupyterHub
|
||||||
follows the Jupyter `Community Guides <https://jupyter.readthedocs.io/en/latest/community/content-community.html>`_.
|
follows the Jupyter `Community Guides <https://jupyter.readthedocs.io/en/latest/community/content-community.html>`_.
|
||||||
@@ -155,4 +155,3 @@ Questions? Suggestions?
|
|||||||
|
|
||||||
.. _JupyterHub: https://github.com/jupyterhub/jupyterhub
|
.. _JupyterHub: https://github.com/jupyterhub/jupyterhub
|
||||||
.. _Jupyter notebook: https://jupyter-notebook.readthedocs.io/en/latest/
|
.. _Jupyter notebook: https://jupyter-notebook.readthedocs.io/en/latest/
|
||||||
.. _REST API: https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/jupyterhub/jupyterhub/HEAD/docs/rest-api.yml#!/default
|
|
||||||
|
@@ -5,8 +5,8 @@
|
|||||||
Before installing JupyterHub, you will need:
|
Before installing JupyterHub, you will need:
|
||||||
|
|
||||||
- a Linux/Unix based system
|
- a Linux/Unix based system
|
||||||
- [Python](https://www.python.org/downloads/) 3.5 or greater. An understanding
|
- [Python](https://www.python.org/downloads/) 3.6 or greater. An understanding
|
||||||
of using [`pip`](https://pip.pypa.io/en/stable/) or
|
of using [`pip`](https://pip.pypa.io) or
|
||||||
[`conda`](https://conda.io/docs/get-started.html) for
|
[`conda`](https://conda.io/docs/get-started.html) for
|
||||||
installing Python packages is helpful.
|
installing Python packages is helpful.
|
||||||
- [nodejs/npm](https://www.npmjs.com/). [Install nodejs/npm](https://docs.npmjs.com/getting-started/installing-node),
|
- [nodejs/npm](https://www.npmjs.com/). [Install nodejs/npm](https://docs.npmjs.com/getting-started/installing-node),
|
||||||
@@ -20,11 +20,11 @@ Before installing JupyterHub, you will need:
|
|||||||
For example, install it on Linux (Debian/Ubuntu) using:
|
For example, install it on Linux (Debian/Ubuntu) using:
|
||||||
|
|
||||||
```
|
```
|
||||||
sudo apt-get install npm nodejs-legacy
|
sudo apt-get install nodejs npm
|
||||||
```
|
```
|
||||||
|
|
||||||
The `nodejs-legacy` package installs the `node` executable and is currently
|
[nodesource][] is a great resource to get more recent versions of the nodejs runtime,
|
||||||
required for npm to work on Debian/Ubuntu.
|
if your system package manager only has an old version of Node.js (e.g. 10 or older).
|
||||||
|
|
||||||
- A [pluggable authentication module (PAM)](https://en.wikipedia.org/wiki/Pluggable_authentication_module)
|
- A [pluggable authentication module (PAM)](https://en.wikipedia.org/wiki/Pluggable_authentication_module)
|
||||||
to use the [default Authenticator](./getting-started/authenticators-users-basics.md).
|
to use the [default Authenticator](./getting-started/authenticators-users-basics.md).
|
||||||
@@ -33,11 +33,17 @@ Before installing JupyterHub, you will need:
|
|||||||
- TLS certificate and key for HTTPS communication
|
- TLS certificate and key for HTTPS communication
|
||||||
- Domain name
|
- Domain name
|
||||||
|
|
||||||
|
[nodesource]: https://github.com/nodesource/distributions#table-of-contents
|
||||||
|
|
||||||
Before running the single-user notebook servers (which may be on the same
|
Before running the single-user notebook servers (which may be on the same
|
||||||
system as the Hub or not), you will need:
|
system as the Hub or not), you will need:
|
||||||
|
|
||||||
- [Jupyter Notebook](https://jupyter.readthedocs.io/en/latest/install.html)
|
- [JupyterLab][] version 3 or greater,
|
||||||
version 4 or greater
|
or [Jupyter Notebook][]
|
||||||
|
4 or greater.
|
||||||
|
|
||||||
|
[jupyterlab]: https://jupyterlab.readthedocs.io
|
||||||
|
[jupyter notebook]: https://jupyter.readthedocs.io/en/latest/install.html
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -48,14 +54,14 @@ JupyterHub can be installed with `pip` (and the proxy with `npm`) or `conda`:
|
|||||||
```bash
|
```bash
|
||||||
python3 -m pip install jupyterhub
|
python3 -m pip install jupyterhub
|
||||||
npm install -g configurable-http-proxy
|
npm install -g configurable-http-proxy
|
||||||
python3 -m pip install notebook # needed if running the notebook servers locally
|
python3 -m pip install jupyterlab notebook # needed if running the notebook servers in the same environment
|
||||||
```
|
```
|
||||||
|
|
||||||
**conda** (one command installs jupyterhub and proxy):
|
**conda** (one command installs jupyterhub and proxy):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
conda install -c conda-forge jupyterhub # installs jupyterhub and proxy
|
conda install -c conda-forge jupyterhub # installs jupyterhub and proxy
|
||||||
conda install notebook # needed if running the notebook servers locally
|
conda install jupyterlab notebook # needed if running the notebook servers in the same environment
|
||||||
```
|
```
|
||||||
|
|
||||||
Test your installation. If installed, these commands should return the packages'
|
Test your installation. If installed, these commands should return the packages'
|
||||||
@@ -74,7 +80,7 @@ To start the Hub server, run the command:
|
|||||||
jupyterhub
|
jupyterhub
|
||||||
```
|
```
|
||||||
|
|
||||||
Visit `https://localhost:8000` in your browser, and sign in with your unix
|
Visit `http://localhost:8000` in your browser, and sign in with your unix
|
||||||
credentials.
|
credentials.
|
||||||
|
|
||||||
To **allow multiple users to sign in** to the Hub server, you must start
|
To **allow multiple users to sign in** to the Hub server, you must start
|
||||||
|
@@ -1,14 +1,33 @@
|
|||||||
|
"""
|
||||||
|
This script updates two files with the RBAC scope descriptions found in
|
||||||
|
`scopes.py`.
|
||||||
|
|
||||||
|
The files are:
|
||||||
|
|
||||||
|
1. scope-table.md
|
||||||
|
|
||||||
|
This file is git ignored and referenced by the documentation.
|
||||||
|
|
||||||
|
2. rest-api.yml
|
||||||
|
|
||||||
|
This file is JupyterHub's REST API schema. Both a version and the RBAC
|
||||||
|
scopes descriptions are updated in it.
|
||||||
|
"""
|
||||||
import os
|
import os
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from subprocess import run
|
||||||
|
|
||||||
from pytablewriter import MarkdownTableWriter
|
from pytablewriter import MarkdownTableWriter
|
||||||
from ruamel.yaml import YAML
|
from ruamel.yaml import YAML
|
||||||
|
|
||||||
|
from jupyterhub import __version__
|
||||||
from jupyterhub.scopes import scope_definitions
|
from jupyterhub.scopes import scope_definitions
|
||||||
|
|
||||||
HERE = os.path.abspath(os.path.dirname(__file__))
|
HERE = os.path.abspath(os.path.dirname(__file__))
|
||||||
PARENT = Path(HERE).parent.parent.absolute()
|
DOCS = Path(HERE).parent.parent.absolute()
|
||||||
|
REST_API_YAML = DOCS.joinpath("source", "_static", "rest-api.yml")
|
||||||
|
SCOPE_TABLE_MD = Path(HERE).joinpath("scope-table.md")
|
||||||
|
|
||||||
|
|
||||||
class ScopeTableGenerator:
|
class ScopeTableGenerator:
|
||||||
@@ -80,8 +99,9 @@ class ScopeTableGenerator:
|
|||||||
return table_rows
|
return table_rows
|
||||||
|
|
||||||
def write_table(self):
|
def write_table(self):
|
||||||
"""Generates the scope table in markdown format and writes it into `scope-table.md`"""
|
"""Generates the RBAC scopes reference documentation as a markdown table
|
||||||
filename = f"{HERE}/scope-table.md"
|
and writes it to the .gitignored `scope-table.md`."""
|
||||||
|
filename = SCOPE_TABLE_MD
|
||||||
table_name = ""
|
table_name = ""
|
||||||
headers = ["Scope", "Grants permission to:"]
|
headers = ["Scope", "Grants permission to:"]
|
||||||
values = self._parse_scopes()
|
values = self._parse_scopes()
|
||||||
@@ -97,23 +117,38 @@ class ScopeTableGenerator:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def write_api(self):
|
def write_api(self):
|
||||||
"""Generates the API description in markdown format and writes it into `rest-api.yml`"""
|
"""Loads `rest-api.yml` and writes it back with a dynamically set
|
||||||
filename = f"{PARENT}/rest-api.yml"
|
JupyterHub version field and list of RBAC scopes descriptions from
|
||||||
yaml = YAML(typ='rt')
|
`scopes.py`."""
|
||||||
|
filename = REST_API_YAML
|
||||||
|
|
||||||
|
yaml = YAML(typ="rt")
|
||||||
yaml.preserve_quotes = True
|
yaml.preserve_quotes = True
|
||||||
|
yaml.indent(mapping=2, offset=2, sequence=4)
|
||||||
|
|
||||||
scope_dict = {}
|
scope_dict = {}
|
||||||
with open(filename, 'r+') as f:
|
with open(filename) as f:
|
||||||
content = yaml.load(f.read())
|
content = yaml.load(f.read())
|
||||||
f.seek(0)
|
|
||||||
|
content["info"]["version"] = __version__
|
||||||
for scope in self.scopes:
|
for scope in self.scopes:
|
||||||
description = self.scopes[scope]['description']
|
description = self.scopes[scope]['description']
|
||||||
doc_description = self.scopes[scope].get('doc_description', '')
|
doc_description = self.scopes[scope].get('doc_description', '')
|
||||||
if doc_description:
|
if doc_description:
|
||||||
description = doc_description
|
description = doc_description
|
||||||
scope_dict[scope] = description
|
scope_dict[scope] = description
|
||||||
content['securityDefinitions']['oauth2']['scopes'] = scope_dict
|
content['components']['securitySchemes']['oauth2']['flows'][
|
||||||
|
'authorizationCode'
|
||||||
|
]['scopes'] = scope_dict
|
||||||
|
|
||||||
|
with open(filename, 'w') as f:
|
||||||
yaml.dump(content, f)
|
yaml.dump(content, f)
|
||||||
f.truncate()
|
|
||||||
|
run(
|
||||||
|
['pre-commit', 'run', 'prettier', '--files', filename],
|
||||||
|
cwd=HERE,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
@@ -1,3 +1,5 @@
|
|||||||
|
(RBAC)=
|
||||||
|
|
||||||
# JupyterHub RBAC
|
# JupyterHub RBAC
|
||||||
|
|
||||||
Role Based Access Control (RBAC) in JupyterHub serves to provide fine grained control of access to Jupyterhub's API resources.
|
Role Based Access Control (RBAC) in JupyterHub serves to provide fine grained control of access to Jupyterhub's API resources.
|
||||||
|
@@ -7,7 +7,7 @@ JupyterHub provides four roles that are available by default:
|
|||||||
```{admonition} **Default roles**
|
```{admonition} **Default roles**
|
||||||
- `user` role provides a {ref}`default user scope <default-user-scope-target>` `self` that grants access to the user's own resources.
|
- `user` role provides a {ref}`default user scope <default-user-scope-target>` `self` that grants access to the user's own resources.
|
||||||
- `admin` role contains all available scopes and grants full rights to all actions. This role **cannot be edited**.
|
- `admin` role contains all available scopes and grants full rights to all actions. This role **cannot be edited**.
|
||||||
- `token` role provides a {ref}`default token scope <default-token-scope-target>` `all` that resolves to the same permissions as the owner of the token has.
|
- `token` role provides a {ref}`default token scope <default-token-scope-target>` `inherit` that resolves to the same permissions as the owner of the token has.
|
||||||
- `server` role allows for posting activity of "itself" only.
|
- `server` role allows for posting activity of "itself" only.
|
||||||
|
|
||||||
**These roles cannot be deleted.**
|
**These roles cannot be deleted.**
|
||||||
@@ -27,7 +27,6 @@ Roles can be assigned to the following entities:
|
|||||||
- Users
|
- Users
|
||||||
- Services
|
- Services
|
||||||
- Groups
|
- Groups
|
||||||
- Tokens
|
|
||||||
|
|
||||||
An entity can have zero, one, or multiple roles, and there are no restrictions on which roles can be assigned to which entity. Roles can be added to or removed from entities at any time.
|
An entity can have zero, one, or multiple roles, and there are no restrictions on which roles can be assigned to which entity. Roles can be added to or removed from entities at any time.
|
||||||
|
|
||||||
@@ -41,7 +40,7 @@ Services do not have a default role. Services without roles have no access to th
|
|||||||
A group does not require any role, and has no roles by default. If a user is a member of a group, they automatically inherit any of the group's permissions (see {ref}`resolving-roles-scopes-target` for more details). This is useful for assigning a set of common permissions to several users.
|
A group does not require any role, and has no roles by default. If a user is a member of a group, they automatically inherit any of the group's permissions (see {ref}`resolving-roles-scopes-target` for more details). This is useful for assigning a set of common permissions to several users.
|
||||||
|
|
||||||
**Tokens** \
|
**Tokens** \
|
||||||
A token’s permissions are evaluated based on their owning entity. Since a token is always issued for a user or service, it can never have more permissions than its owner. If no specific role is requested for a new token, the token is assigned the `token` role.
|
A token’s permissions are evaluated based on their owning entity. Since a token is always issued for a user or service, it can never have more permissions than its owner. If no specific scopes are requested for a new token, the token is assigned the scopes of the `token` role.
|
||||||
|
|
||||||
(define-role-target)=
|
(define-role-target)=
|
||||||
|
|
||||||
@@ -123,13 +122,13 @@ has,
|
|||||||
define the `server` role.
|
define the `server` role.
|
||||||
|
|
||||||
To restore the JupyterHub 1.x behavior of servers being able to do anything their owners can do,
|
To restore the JupyterHub 1.x behavior of servers being able to do anything their owners can do,
|
||||||
use the scope `all`:
|
use the scope `inherit` (for 'inheriting' the owner's permissions):
|
||||||
|
|
||||||
```python
|
```python
|
||||||
c.JupyterHub.load_roles = [
|
c.JupyterHub.load_roles = [
|
||||||
{
|
{
|
||||||
'name': 'server',
|
'name': 'server',
|
||||||
'scopes': ['all'],
|
'scopes': ['inherit'],
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
@@ -38,7 +38,7 @@ By adding a scope to an existing role, all role bearers will gain the associated
|
|||||||
Metascopes do not follow the general scope syntax. Instead, a metascope resolves to a set of scopes, which can refer to different resources, based on their owning entity. In JupyterHub, there are currently two metascopes:
|
Metascopes do not follow the general scope syntax. Instead, a metascope resolves to a set of scopes, which can refer to different resources, based on their owning entity. In JupyterHub, there are currently two metascopes:
|
||||||
|
|
||||||
1. default user scope `self`, and
|
1. default user scope `self`, and
|
||||||
2. default token scope `all`.
|
2. default token scope `inherit`.
|
||||||
|
|
||||||
(default-user-scope-target)=
|
(default-user-scope-target)=
|
||||||
|
|
||||||
@@ -57,11 +57,11 @@ The `self` scope is only valid for user entities. In other cases (e.g., for serv
|
|||||||
|
|
||||||
### Default token scope
|
### Default token scope
|
||||||
|
|
||||||
The token metascope `all` covers the same scopes as the token owner's scopes during requests. For example, if a token owner has roles containing the scopes `read:groups` and `read:users`, the `all` scope resolves to the set of scopes `{read:groups, read:users}`.
|
The token metascope `inherit` causes the token to have the same permissions as the token's owner. For example, if a token owner has roles containing the scopes `read:groups` and `read:users`, the `inherit` scope resolves to the set of scopes `{read:groups, read:users}`.
|
||||||
|
|
||||||
If the token owner has default `user` role, the `all` scope resolves to `self`, which will subsequently be expanded to include all the user-specific scopes (or empty set in the case of services).
|
If the token owner has default `user` role, the `inherit` scope resolves to `self`, which will subsequently be expanded to include all the user-specific scopes (or empty set in the case of services).
|
||||||
|
|
||||||
If the token owner is a member of any group with roles, the group scopes will also be included in resolving the `all` scope.
|
If the token owner is a member of any group with roles, the group scopes will also be included in resolving the `inherit` scope.
|
||||||
|
|
||||||
(horizontal-filtering-target)=
|
(horizontal-filtering-target)=
|
||||||
|
|
||||||
@@ -72,13 +72,31 @@ Requested resources are filtered based on the filter of the corresponding scope.
|
|||||||
|
|
||||||
In case a user resource is being accessed, any scopes with _group_ filters will be expanded to filters for each _user_ in those groups.
|
In case a user resource is being accessed, any scopes with _group_ filters will be expanded to filters for each _user_ in those groups.
|
||||||
|
|
||||||
### `!user` filter
|
(self-referencing-filters)=
|
||||||
|
|
||||||
|
### Self-referencing filters
|
||||||
|
|
||||||
|
There are some 'shortcut' filters,
|
||||||
|
which can be applied to all scopes,
|
||||||
|
that filter based on the entities associated with the request.
|
||||||
|
|
||||||
The `!user` filter is a special horizontal filter that strictly refers to the **"owner only"** scopes, where _owner_ is a user entity. The filter resolves internally into `!user=<ownerusername>` ensuring that only the owner's resources may be accessed through the associated scopes.
|
The `!user` filter is a special horizontal filter that strictly refers to the **"owner only"** scopes, where _owner_ is a user entity. The filter resolves internally into `!user=<ownerusername>` ensuring that only the owner's resources may be accessed through the associated scopes.
|
||||||
|
|
||||||
For example, the `server` role assigned by default to server tokens contains `access:servers!user` and `users:activity!user` scopes. This allows the token to access and post activity of only the servers owned by the token owner.
|
For example, the `server` role assigned by default to server tokens contains `access:servers!user` and `users:activity!user` scopes. This allows the token to access and post activity of only the servers owned by the token owner.
|
||||||
|
|
||||||
The filter can be applied to any scope.
|
:::{versionadded} 3.0
|
||||||
|
`!service` and `!server` filters.
|
||||||
|
:::
|
||||||
|
|
||||||
|
In addition to `!user`, _tokens_ may have filters `!service`
|
||||||
|
or `!server`, which expand similarly to `!service=servicename`
|
||||||
|
and `!server=servername`.
|
||||||
|
This only applies to tokens issued via the OAuth flow.
|
||||||
|
In these cases, the name is the _issuing_ entity (a service or single-user server),
|
||||||
|
so that access can be restricted to the issuing service,
|
||||||
|
e.g. `access:servers!server` would grant access only to the server that requested the token.
|
||||||
|
|
||||||
|
These filters can be applied to any scope.
|
||||||
|
|
||||||
(vertical-filtering-target)=
|
(vertical-filtering-target)=
|
||||||
|
|
||||||
@@ -114,11 +132,170 @@ There are four exceptions to the general {ref}`scope conventions <scope-conventi
|
|||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
:::{versionadded} 3.0
|
||||||
|
The `admin-ui` scope is added to explicitly grant access to the admin page,
|
||||||
|
rather than combining `admin:users` and `admin:servers` permissions.
|
||||||
|
This means a deployment can enable the admin page with only a subset of functionality enabled.
|
||||||
|
|
||||||
|
Note that this means actions to take _via_ the admin UI
|
||||||
|
and access _to_ the admin UI are separated.
|
||||||
|
For example, it generally doesn't make sense to grant
|
||||||
|
`admin-ui` without at least `list:users` for at least some subset of users.
|
||||||
|
|
||||||
|
For example:
|
||||||
|
|
||||||
|
```python
|
||||||
|
c.JupyterHub.load_roles = [
|
||||||
|
{
|
||||||
|
"name": "instructor-data8",
|
||||||
|
"scopes": [
|
||||||
|
# access to the admin page
|
||||||
|
"admin-ui",
|
||||||
|
# list users in the class group
|
||||||
|
"list:users!group=students-data8",
|
||||||
|
# start/stop servers for users in the class
|
||||||
|
"admin:servers!group=students-data8",
|
||||||
|
# access servers for users in the class
|
||||||
|
"access:servers!group=students-data8",
|
||||||
|
],
|
||||||
|
"group": ["instructors-data8"],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
will grant instructors in the data8 course permission to:
|
||||||
|
|
||||||
|
1. view the admin UI
|
||||||
|
2. see students in the class (but not all users)
|
||||||
|
3. start/stop/access servers for users in the class
|
||||||
|
4. but _not_ permission to administer the users themselves (e.g. change their permissions, etc.)
|
||||||
|
:::
|
||||||
|
|
||||||
```{Caution}
|
```{Caution}
|
||||||
Note that only the {ref}`horizontal filtering <horizontal-filtering-target>` can be added to scopes to customize them. \
|
Note that only the {ref}`horizontal filtering <horizontal-filtering-target>` can be added to scopes to customize them. \
|
||||||
Metascopes `self` and `all`, `<resource>`, `<resource>:<subresource>`, `read:<resource>`, `admin:<resource>`, and `access:<resource>` scopes are predefined and cannot be changed otherwise.
|
Metascopes `self` and `all`, `<resource>`, `<resource>:<subresource>`, `read:<resource>`, `admin:<resource>`, and `access:<resource>` scopes are predefined and cannot be changed otherwise.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
(custom-scopes)=
|
||||||
|
|
||||||
|
### Custom scopes
|
||||||
|
|
||||||
|
:::{versionadded} 3.0
|
||||||
|
:::
|
||||||
|
|
||||||
|
JupyterHub 3.0 introduces support for custom scopes.
|
||||||
|
Services that use JupyterHub for authentication and want to implement their own granular access may define additional _custom_ scopes and assign them to users with JupyterHub roles.
|
||||||
|
|
||||||
|
% Note: keep in sync with pattern/description in jupyterhub/scopes.py
|
||||||
|
|
||||||
|
Custom scope names must start with `custom:`
|
||||||
|
and contain only lowercase ascii letters, numbers, hyphen, underscore, colon, and asterisk (`-_:*`).
|
||||||
|
The part after `custom:` must start with a letter or number.
|
||||||
|
Scopes may not end with a hyphen or colon.
|
||||||
|
|
||||||
|
The only strict requirement is that a custom scope definition must have a `description`.
|
||||||
|
It _may_ also have `subscopes` if you are defining multiple scopes that have a natural hierarchy,
|
||||||
|
|
||||||
|
For example:
|
||||||
|
|
||||||
|
```python
|
||||||
|
c.JupyterHub.custom_scopes = {
|
||||||
|
"custom:myservice:read": {
|
||||||
|
"description": "read-only access to myservice",
|
||||||
|
},
|
||||||
|
"custom:myservice:write": {
|
||||||
|
"description": "write access to myservice",
|
||||||
|
# write permission implies read permission
|
||||||
|
"subscopes": [
|
||||||
|
"custom:myservice:read",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JupyterHub.load_roles = [
|
||||||
|
# graders have read-only access to the service
|
||||||
|
{
|
||||||
|
"name": "service-user",
|
||||||
|
"groups": ["graders"],
|
||||||
|
"scopes": [
|
||||||
|
"custom:myservice:read",
|
||||||
|
"access:service!service=myservice",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
# instructors have read and write access to the service
|
||||||
|
{
|
||||||
|
"name": "service-admin",
|
||||||
|
"groups": ["instructors"],
|
||||||
|
"scopes": [
|
||||||
|
"custom:myservice:write",
|
||||||
|
"access:service!service=myservice",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
In the above configuration, two scopes are defined:
|
||||||
|
|
||||||
|
- `custom:myservice:read` grants read-only access to the service, and
|
||||||
|
- `custom:myservice:write` grants write access to the service
|
||||||
|
- write access _implies_ read access via the `subscope`
|
||||||
|
|
||||||
|
These custom scopes are assigned to two groups via `roles`:
|
||||||
|
|
||||||
|
- users in the group `graders` are granted read access to the service
|
||||||
|
- users in the group `instructors` are
|
||||||
|
- both are granted _access_ to the service via `access:service!service=myservice`
|
||||||
|
|
||||||
|
When the service completes OAuth, it will retrieve the user model from `/hub/api/user`.
|
||||||
|
This model includes a `scopes` field which is a list of authorized scope for the request,
|
||||||
|
which can be used.
|
||||||
|
|
||||||
|
```python
|
||||||
|
def require_scope(scope):
|
||||||
|
"""decorator to require a scope to perform an action"""
|
||||||
|
def wrapper(func):
|
||||||
|
@functools.wraps(func)
|
||||||
|
def wrapped_func(request):
|
||||||
|
user = fetch_hub_api_user(request.token)
|
||||||
|
if scope not in user["scopes"]:
|
||||||
|
raise HTTP403(f"Requires scope {scope}")
|
||||||
|
else:
|
||||||
|
return func()
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
@require_scope("custom:myservice:read")
|
||||||
|
async def read_something(request):
|
||||||
|
...
|
||||||
|
|
||||||
|
@require_scope("custom:myservice:write")
|
||||||
|
async def write_something(request):
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
If you use {class}`~.HubOAuthenticated`, this check is performed automatically
|
||||||
|
against the `.hub_scopes` attribute of each Handler
|
||||||
|
(the default is populated from `$JUPYTERHUB_OAUTH_ACCESS_SCOPES` and usually `access:services!service=myservice`).
|
||||||
|
|
||||||
|
:::{versionchanged} 3.0
|
||||||
|
The JUPYTERHUB_OAUTH_SCOPES environment variable is deprecated and renamed to JUPYTERHUB_OAUTH_ACCESS_SCOPES,
|
||||||
|
to avoid ambiguity with JUPYTERHUB_OAUTH_CLIENT_ALLOWED_SCOPES
|
||||||
|
:::
|
||||||
|
|
||||||
|
```python
|
||||||
|
from tornado import web
|
||||||
|
from jupyterhub.services.auth import HubOAuthenticated
|
||||||
|
|
||||||
|
class MyHandler(HubOAuthenticated, BaseHandler):
|
||||||
|
hub_scopes = ["custom:myservice:read"]
|
||||||
|
|
||||||
|
@web.authenticated
|
||||||
|
def get(self):
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Existing scope filters (`!user=`, etc.) may be applied to custom scopes.
|
||||||
|
Custom scope _filters_ are NOT supported.
|
||||||
|
|
||||||
### Scopes and APIs
|
### Scopes and APIs
|
||||||
|
|
||||||
The scopes are also listed in the [](../reference/rest-api.rst) documentation. Each API endpoint has a list of scopes which can be used to access the API; if no scopes are listed, the API is not authenticated and can be accessed without any permissions (i.e., no scopes).
|
The scopes are also listed in the [](../reference/rest-api.rst) documentation. Each API endpoint has a list of scopes which can be used to access the API; if no scopes are listed, the API is not authenticated and can be accessed without any permissions (i.e., no scopes).
|
||||||
|
@@ -7,11 +7,11 @@ Roles and scopes utilities can be found in `roles.py` and `scopes.py` modules. S
|
|||||||
```{admonition} **Scope variable nomenclature**
|
```{admonition} **Scope variable nomenclature**
|
||||||
:class: tip
|
:class: tip
|
||||||
- _scopes_ \
|
- _scopes_ \
|
||||||
List of scopes with abbreviations (used in role definitions). E.g., `["users:activity!user"]`.
|
List of scopes that may contain abbreviations (used in role definitions). E.g., `["users:activity!user", "self"]`.
|
||||||
- _expanded scopes_ \
|
- _expanded scopes_ \
|
||||||
Set of expanded scopes without abbreviations (i.e., resolved metascopes, filters and subscopes). E.g., `{"users:activity!user=charlie", "read:users:activity!user=charlie"}`.
|
Set of fully expanded scopes without abbreviations (i.e., resolved metascopes, filters, and subscopes). E.g., `{"users:activity!user=charlie", "read:users:activity!user=charlie"}`.
|
||||||
- _parsed scopes_ \
|
- _parsed scopes_ \
|
||||||
Dictionary JSON like format of expanded scopes. E.g., `{"users:activity": {"user": ["charlie"]}, "read:users:activity": {"users": ["charlie"]}}`.
|
Dictionary represenation of expanded scopes. E.g., `{"users:activity": {"user": ["charlie"]}, "read:users:activity": {"users": ["charlie"]}}`.
|
||||||
- _intersection_ \
|
- _intersection_ \
|
||||||
Set of expanded scopes as intersection of 2 expanded scope sets.
|
Set of expanded scopes as intersection of 2 expanded scope sets.
|
||||||
- _identify scopes_ \
|
- _identify scopes_ \
|
||||||
@@ -22,27 +22,47 @@ Roles and scopes utilities can be found in `roles.py` and `scopes.py` modules. S
|
|||||||
|
|
||||||
## Resolving roles and scopes
|
## Resolving roles and scopes
|
||||||
|
|
||||||
**Resolving roles** refers to determining which roles a user, service, token, or group has, extracting the list of scopes from each role and combining them into a single set of scopes.
|
**Resolving roles** refers to determining which roles a user, service, or group has, extracting the list of scopes from each role and combining them into a single set of scopes.
|
||||||
|
|
||||||
**Resolving scopes** involves expanding scopes into all their possible subscopes (_expanded scopes_), parsing them into format used for access evaluation (_parsed scopes_) and, if applicable, comparing two sets of scopes (_intersection_). All procedures take into account the scope hierarchy, {ref}`vertical <vertical-filtering-target>` and {ref}`horizontal filtering <horizontal-filtering-target>`, limiting or elevated permissions (`read:<resource>` or `admin:<resource>`, respectively), and metascopes.
|
**Resolving scopes** involves expanding scopes into all their possible subscopes (_expanded scopes_), parsing them into format used for access evaluation (_parsed scopes_) and, if applicable, comparing two sets of scopes (_intersection_). All procedures take into account the scope hierarchy, {ref}`vertical <vertical-filtering-target>` and {ref}`horizontal filtering <horizontal-filtering-target>`, limiting or elevated permissions (`read:<resource>` or `admin:<resource>`, respectively), and metascopes.
|
||||||
|
|
||||||
Roles and scopes are resolved on several occasions, for example when requesting an API token with specific roles or making an API request. The following sections provide more details.
|
Roles and scopes are resolved on several occasions, for example when requesting an API token with specific scopes or making an API request. The following sections provide more details.
|
||||||
|
|
||||||
(requesting-api-token-target)=
|
(requesting-api-token-target)=
|
||||||
|
|
||||||
### Requesting API token with specific roles
|
### Requesting API token with specific scopes
|
||||||
|
|
||||||
API tokens grant access to JupyterHub's APIs. The RBAC framework allows for requesting tokens with specific existing roles. To date, it is only possible to add roles to a token through the _POST /users/:name/tokens_ API where the roles can be specified in the token parameters body (see [](../reference/rest-api.rst)).
|
:::{versionchanged} 3.0
|
||||||
|
API tokens have _scopes_ instead of roles,
|
||||||
|
so that their permissions cannot be updated.
|
||||||
|
|
||||||
RBAC adds several steps into the token issue flow.
|
You may still request roles for a token,
|
||||||
|
but those roles will be evaluated to the corresponding _scopes_ immediately.
|
||||||
|
|
||||||
If no roles are requested, the token is issued with the default `token` role (providing the requester is allowed to create the token).
|
Prior to 3.0, tokens stored _roles_,
|
||||||
|
which meant their scopes were resolved on each request.
|
||||||
|
:::
|
||||||
|
|
||||||
If the token is requested with any roles, the permissions of requesting entity are checked against the requested permissions to ensure the token would not grant its owner additional privileges.
|
API tokens grant access to JupyterHub's APIs. The RBAC framework allows for requesting tokens with specific permissions.
|
||||||
|
|
||||||
If, due to modifications of roles or entities, at API request time a token has any scopes that its owner does not, those scopes are removed. The API request is resolved without additional errors using the scopes _intersection_, but the Hub logs a warning (see {ref}`Figure 2 <api-request-chart>`).
|
RBAC is involved in several stages of the OAuth token flow.
|
||||||
|
|
||||||
Resolving a token's roles (yellow box in {ref}`Figure 1 <token-request-chart>`) corresponds to resolving all the token's owner roles (including the roles associated with their groups) and the token's requested roles into a set of scopes. The two sets are compared (Resolve the scopes box in orange in {ref}`Figure 1 <token-request-chart>`), taking into account the scope hierarchy but, solely for role assignment, omitting any {ref}`horizontal filter <horizontal-filtering-target>` comparison. If the token's scopes are a subset of the token owner's scopes, the token is issued with the requested roles; if not, JupyterHub will raise an error.
|
When requesting a token via the tokens API (`/users/:name/tokens`), or the token page (`/hub/token`),
|
||||||
|
if no scopes are requested, the token is issued with the permissions stored on the default `token` role
|
||||||
|
(providing the requester is allowed to create the token).
|
||||||
|
|
||||||
|
OAuth tokens are also requested via OAuth flow
|
||||||
|
|
||||||
|
If the token is requested with any scopes, the permissions of requesting entity are checked against the requested permissions to ensure the token would not grant its owner additional privileges.
|
||||||
|
|
||||||
|
If, due to modifications of permissions of the token or token owner,
|
||||||
|
at API request time a token has any scopes that its owner does not,
|
||||||
|
those scopes are removed.
|
||||||
|
The API request is resolved without additional errors using the scope _intersection_;
|
||||||
|
the Hub logs a warning in this case (see {ref}`Figure 2 <api-request-chart>`).
|
||||||
|
|
||||||
|
Resolving a token's scope (yellow box in {ref}`Figure 1 <token-request-chart>`) corresponds to resolving all the token's owner roles (including the roles associated with their groups) and the token's own scopes into a set of scopes. The two sets are compared (Resolve the scopes box in orange in {ref}`Figure 1 <token-request-chart>`), taking into account the scope hierarchy.
|
||||||
|
If the token's scopes are a subset of the token owner's scopes, the token is issued with the requested scopes; if not, JupyterHub will raise an error.
|
||||||
|
|
||||||
{ref}`Figure 1 <token-request-chart>` below illustrates the steps involved. The orange rectangles highlight where in the process the roles and scopes are resolved.
|
{ref}`Figure 1 <token-request-chart>` below illustrates the steps involved. The orange rectangles highlight where in the process the roles and scopes are resolved.
|
||||||
|
|
||||||
@@ -55,9 +75,9 @@ Figure 1. Resolving roles and scopes during API token request
|
|||||||
|
|
||||||
### Making an API request
|
### Making an API request
|
||||||
|
|
||||||
With the RBAC framework each authenticated JupyterHub API request is guarded by a scope decorator that specifies which scopes are required to gain the access to the API.
|
With the RBAC framework, each authenticated JupyterHub API request is guarded by a scope decorator that specifies which scopes are required to gain the access to the API.
|
||||||
|
|
||||||
When an API request is performed, the requesting API token's roles are again resolved (yellow box in {ref}`Figure 2 <api-request-chart>`) to ensure the token does not grant more permissions than its owner has at the request time (e.g., due to changing/losing roles).
|
When an API request is performed, the requesting API token's scopes are again intersected with its owner's (yellow box in {ref}`Figure 2 <api-request-chart>`) to ensure the token does not grant more permissions than its owner has at the request time (e.g., due to changing/losing roles).
|
||||||
If the owner's roles do not include some scopes of the token's scopes, only the _intersection_ of the token's and owner's scopes will be used. For example, using a token with scope `users` whose owner's role scope is `read:users:name` will result in only the `read:users:name` scope being passed on. In the case of no _intersection_, an empty set of scopes will be used.
|
If the owner's roles do not include some scopes of the token's scopes, only the _intersection_ of the token's and owner's scopes will be used. For example, using a token with scope `users` whose owner's role scope is `read:users:name` will result in only the `read:users:name` scope being passed on. In the case of no _intersection_, an empty set of scopes will be used.
|
||||||
|
|
||||||
The passed scopes are compared to the scopes required to access the API as follows:
|
The passed scopes are compared to the scopes required to access the API as follows:
|
||||||
|
@@ -49,6 +49,6 @@ API tokens can also be issued to users via API ([_/hub/token_](../reference/urls
|
|||||||
|
|
||||||
### With RBAC
|
### With RBAC
|
||||||
|
|
||||||
The RBAC framework allows for granting tokens different levels of permissions via scopes attached to roles. The 'only identify' purpose of the separate OAuth tokens is no longer required. API tokens can be used used for every action, including the login and authentication, for which an API token with no role (i.e., no scope in {ref}`available-scopes-target`) is used.
|
The RBAC framework allows for granting tokens different levels of permissions via scopes attached to roles. The 'only identify' purpose of the separate OAuth tokens is no longer required. API tokens can be used for every action, including the login and authentication, for which an API token with no role (i.e., no scope in {ref}`available-scopes-target`) is used.
|
||||||
|
|
||||||
OAuth tokens are therefore dropped from the Hub upgraded with the RBAC framework.
|
OAuth tokens are therefore dropped from the Hub upgraded with the RBAC framework.
|
||||||
|
128
docs/source/reference/api-only.md
Normal file
128
docs/source/reference/api-only.md
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
(api-only)=
|
||||||
|
|
||||||
|
# Deploying JupyterHub in "API only mode"
|
||||||
|
|
||||||
|
As a service for deploying and managing Jupyter servers for users, JupyterHub
|
||||||
|
exposes this functionality _primarily_ via a [REST API](rest).
|
||||||
|
For convenience, JupyterHub also ships with a _basic_ web UI built using that REST API.
|
||||||
|
The basic web UI enables users to click a button to quickly start and stop their servers,
|
||||||
|
and it lets admins perform some basic user and server management tasks.
|
||||||
|
|
||||||
|
The REST API has always provided additional functionality beyond what is available in the basic web UI.
|
||||||
|
Similarly, we avoid implementing UI functionality that is also not available via the API.
|
||||||
|
With JupyterHub 2.0, the basic web UI will **always** be composed using the REST API.
|
||||||
|
In other words, no UI pages should rely on information not available via the REST API.
|
||||||
|
Previously, some admin UI functionality could only be achieved via admin pages,
|
||||||
|
such as paginated requests.
|
||||||
|
|
||||||
|
## Limited UI customization via templates
|
||||||
|
|
||||||
|
The JupyterHub UI is customizable via extensible HTML [templates](templates),
|
||||||
|
but this has some limited scope to what can be customized.
|
||||||
|
Adding some content and messages to existing pages is well supported,
|
||||||
|
but changing the page flow and what pages are available are beyond the scope of what is customizable.
|
||||||
|
|
||||||
|
## Rich UI customization with REST API based apps
|
||||||
|
|
||||||
|
Increasingly, JupyterHub is used purely as an API for managing Jupyter servers
|
||||||
|
for other Jupyter-based applications that might want to present a different user experience.
|
||||||
|
If you want a fully customized user experience,
|
||||||
|
you can now disable the Hub UI and use your own pages together with the JupyterHub REST API
|
||||||
|
to build your own web application to serve your users,
|
||||||
|
relying on the Hub only as an API for managing users and servers.
|
||||||
|
|
||||||
|
One example of such an application is [BinderHub][], which powers https://mybinder.org,
|
||||||
|
and motivates many of these changes.
|
||||||
|
|
||||||
|
BinderHub is distinct from a traditional JupyterHub deployment
|
||||||
|
because it uses temporary users created for each launch.
|
||||||
|
Instead of presenting a login page,
|
||||||
|
users are presented with a form to specify what environment they would like to launch:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
When a launch is requested:
|
||||||
|
|
||||||
|
1. an image is built, if necessary
|
||||||
|
2. a temporary user is created,
|
||||||
|
3. a server is launched for that user, and
|
||||||
|
4. when running, users are redirected to an already running server with an auth token in the URL
|
||||||
|
5. after the session is over, the user is deleted
|
||||||
|
|
||||||
|
This means that a lot of JupyterHub's UI flow doesn't make sense:
|
||||||
|
|
||||||
|
- there is no way for users to login
|
||||||
|
- the human user doesn't map onto a JupyterHub `User` in a meaningful way
|
||||||
|
- when a server isn't running, there isn't a 'restart your server' action available because the user has been deleted
|
||||||
|
- users do not have any access to any Hub functionality, so presenting pages for those features would be confusing
|
||||||
|
|
||||||
|
BinderHub is one of the motivating use cases for JupyterHub supporting being used _only_ via its API.
|
||||||
|
We'll use BinderHub here as an example of various configuration options.
|
||||||
|
|
||||||
|
[binderhub]: https://binderhub.readthedocs.io
|
||||||
|
|
||||||
|
## Disabling Hub UI
|
||||||
|
|
||||||
|
`c.JupyterHub.hub_routespec` is a configuration option to specify which URL prefix should be routed to the Hub.
|
||||||
|
The default is `/` which means that the Hub will receive all requests not already specified to be routed somewhere else.
|
||||||
|
|
||||||
|
There are three values that are most logical for `hub_routespec`:
|
||||||
|
|
||||||
|
- `/` - this is the default, and used in most deployments.
|
||||||
|
It is also the only option prior to JupyterHub 1.4.
|
||||||
|
- `/hub/` - this serves only Hub pages, both UI and API
|
||||||
|
- `/hub/api` - this serves _only the Hub API_, so all Hub UI is disabled,
|
||||||
|
aside from the OAuth confirmation page, if used.
|
||||||
|
|
||||||
|
If you choose a hub routespec other than `/`,
|
||||||
|
the main JupyterHub feature you will lose is the automatic handling of requests for `/user/:username`
|
||||||
|
when the requested server is not running.
|
||||||
|
|
||||||
|
JupyterHub's handling of this request shows this page,
|
||||||
|
telling you that the server is not running,
|
||||||
|
with a button to launch it again:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
If you set `hub_routespec` to something other than `/`,
|
||||||
|
it is likely that you also want to register another destination for `/` to handle requests to not-running servers.
|
||||||
|
If you don't, you will see a default 404 page from the proxy:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
For mybinder.org, the default "start my server" page doesn't make sense,
|
||||||
|
because when a server is gone, there is no restart action.
|
||||||
|
Instead, we provide hints about how to get back to a link to start a _new_ server:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
To achieve this, mybinder.org registers a route for `/` that goes to a custom endpoint
|
||||||
|
that runs nginx and only serves this static HTML error page.
|
||||||
|
This is set with
|
||||||
|
|
||||||
|
```python
|
||||||
|
c.Proxy.extra_routes = {
|
||||||
|
"/": "http://custom-404-entpoint/",
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
You may want to use an alternate behavior, such as redirecting to a landing page,
|
||||||
|
or taking some other action based on the requested page.
|
||||||
|
|
||||||
|
If you use `c.JupyterHub.hub_routespec = "/hub/"`,
|
||||||
|
then all the Hub pages will be available,
|
||||||
|
and only this default-page-404 issue will come up.
|
||||||
|
|
||||||
|
If you use `c.JupyterHub.hub_routespec = "/hub/api/"`,
|
||||||
|
then only the Hub _API_ will be available,
|
||||||
|
and all UI will be up to you.
|
||||||
|
mybinder.org takes this last option,
|
||||||
|
because none of the Hub UI pages really make sense.
|
||||||
|
Binder users don't have any reason to know or care that JupyterHub happens
|
||||||
|
to be an implementation detail of how their environment is managed.
|
||||||
|
Seeing Hub error pages and messages in that situation is more likely to be confusing than helpful.
|
||||||
|
|
||||||
|
:::{versionadded} 1.4
|
||||||
|
|
||||||
|
`c.JupyterHub.hub_routespec` and `c.Proxy.extra_routes` are new in JupyterHub 1.4.
|
||||||
|
:::
|
@@ -1,6 +1,6 @@
|
|||||||
# Authenticators
|
# Authenticators
|
||||||
|
|
||||||
The [Authenticator][] is the mechanism for authorizing users to use the
|
The {class}`.Authenticator` is the mechanism for authorizing users to use the
|
||||||
Hub and single user notebook servers.
|
Hub and single user notebook servers.
|
||||||
|
|
||||||
## The default PAM Authenticator
|
## The default PAM Authenticator
|
||||||
@@ -137,8 +137,8 @@ via other mechanisms. One such example is using [GitHub OAuth][].
|
|||||||
|
|
||||||
Because the username is passed from the Authenticator to the Spawner,
|
Because the username is passed from the Authenticator to the Spawner,
|
||||||
a custom Authenticator and Spawner are often used together.
|
a custom Authenticator and Spawner are often used together.
|
||||||
For example, the Authenticator methods, [pre_spawn_start(user, spawner)][]
|
For example, the Authenticator methods, {meth}`.Authenticator.pre_spawn_start`
|
||||||
and [post_spawn_stop(user, spawner)][], are hooks that can be used to do
|
and {meth}`.Authenticator.post_spawn_stop`, are hooks that can be used to do
|
||||||
auth-related startup (e.g. opening PAM sessions) and cleanup
|
auth-related startup (e.g. opening PAM sessions) and cleanup
|
||||||
(e.g. closing PAM sessions).
|
(e.g. closing PAM sessions).
|
||||||
|
|
||||||
@@ -223,7 +223,7 @@ If there are multiple keys present, the **first** key is always used to persist
|
|||||||
|
|
||||||
Typically, if `auth_state` is persisted it is desirable to affect the Spawner environment in some way.
|
Typically, if `auth_state` is persisted it is desirable to affect the Spawner environment in some way.
|
||||||
This may mean defining environment variables, placing certificate in the user's home directory, etc.
|
This may mean defining environment variables, placing certificate in the user's home directory, etc.
|
||||||
The `Authenticator.pre_spawn_start` method can be used to pass information from authenticator state
|
The {meth}`Authenticator.pre_spawn_start` method can be used to pass information from authenticator state
|
||||||
to Spawner environment:
|
to Spawner environment:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@@ -247,10 +247,42 @@ class MyAuthenticator(Authenticator):
|
|||||||
spawner.environment['UPSTREAM_TOKEN'] = auth_state['upstream_token']
|
spawner.environment['UPSTREAM_TOKEN'] = auth_state['upstream_token']
|
||||||
```
|
```
|
||||||
|
|
||||||
|
(authenticator-groups)=
|
||||||
|
|
||||||
|
## Authenticator-managed group membership
|
||||||
|
|
||||||
|
:::{versionadded} 2.2
|
||||||
|
:::
|
||||||
|
|
||||||
|
Some identity providers may have their own concept of group membership that you would like to preserve in JupyterHub.
|
||||||
|
This is now possible with `Authenticator.managed_groups`.
|
||||||
|
|
||||||
|
You can set the config:
|
||||||
|
|
||||||
|
```python
|
||||||
|
c.Authenticator.manage_groups = True
|
||||||
|
```
|
||||||
|
|
||||||
|
to enable this behavior.
|
||||||
|
The default is False for Authenticators that ship with JupyterHub,
|
||||||
|
but may be True for custom Authenticators.
|
||||||
|
Check your Authenticator's documentation for manage_groups support.
|
||||||
|
|
||||||
|
If True, {meth}`.Authenticator.authenticate` and {meth}`.Authenticator.refresh_user` may include a field `groups`
|
||||||
|
which is a list of group names the user should be a member of:
|
||||||
|
|
||||||
|
- Membership will be added for any group in the list
|
||||||
|
- Membership in any groups not in the list will be revoked
|
||||||
|
- Any groups not already present in the database will be created
|
||||||
|
- If `None` is returned, no changes are made to the user's group membership
|
||||||
|
|
||||||
|
If authenticator-managed groups are enabled,
|
||||||
|
all group-management via the API is disabled.
|
||||||
|
|
||||||
## pre_spawn_start and post_spawn_stop hooks
|
## pre_spawn_start and post_spawn_stop hooks
|
||||||
|
|
||||||
Authenticators uses two hooks, [pre_spawn_start(user, spawner)][] and
|
Authenticators uses two hooks, {meth}`.Authenticator.pre_spawn_start` and
|
||||||
[post_spawn_stop(user, spawner)][] to add pass additional state information
|
{meth}`.Authenticator.post_spawn_stop(user, spawner)` to add pass additional state information
|
||||||
between the authenticator and a spawner. These hooks are typically used auth-related
|
between the authenticator and a spawner. These hooks are typically used auth-related
|
||||||
startup, i.e. opening a PAM session, and auth-related cleanup, i.e. closing a
|
startup, i.e. opening a PAM session, and auth-related cleanup, i.e. closing a
|
||||||
PAM session.
|
PAM session.
|
||||||
@@ -259,10 +291,7 @@ PAM session.
|
|||||||
|
|
||||||
Beginning with version 0.8, JupyterHub is an OAuth provider.
|
Beginning with version 0.8, JupyterHub is an OAuth provider.
|
||||||
|
|
||||||
[authenticator]: https://github.com/jupyterhub/jupyterhub/blob/HEAD/jupyterhub/auth.py
|
|
||||||
[pam]: https://en.wikipedia.org/wiki/Pluggable_authentication_module
|
[pam]: https://en.wikipedia.org/wiki/Pluggable_authentication_module
|
||||||
[oauth]: https://en.wikipedia.org/wiki/OAuth
|
[oauth]: https://en.wikipedia.org/wiki/OAuth
|
||||||
[github oauth]: https://developer.github.com/v3/oauth/
|
[github oauth]: https://developer.github.com/v3/oauth/
|
||||||
[oauthenticator]: https://github.com/jupyterhub/oauthenticator
|
[oauthenticator]: https://github.com/jupyterhub/oauthenticator
|
||||||
[pre_spawn_start(user, spawner)]: https://jupyterhub.readthedocs.io/en/latest/api/auth.html#jupyterhub.auth.Authenticator.pre_spawn_start
|
|
||||||
[post_spawn_stop(user, spawner)]: https://jupyterhub.readthedocs.io/en/latest/api/auth.html#jupyterhub.auth.Authenticator.post_spawn_stop
|
|
||||||
|
@@ -165,7 +165,7 @@ As with nginx above, you can use [Apache](https://httpd.apache.org) as the rever
|
|||||||
First, we will need to enable the apache modules that we are going to need:
|
First, we will need to enable the apache modules that we are going to need:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
a2enmod ssl rewrite proxy proxy_http proxy_wstunnel
|
a2enmod ssl rewrite proxy headers proxy_http proxy_wstunnel
|
||||||
```
|
```
|
||||||
|
|
||||||
Our Apache configuration is equivalent to the nginx configuration above:
|
Our Apache configuration is equivalent to the nginx configuration above:
|
||||||
@@ -188,13 +188,24 @@ Listen 443
|
|||||||
|
|
||||||
ServerName HUB.DOMAIN.TLD
|
ServerName HUB.DOMAIN.TLD
|
||||||
|
|
||||||
|
# enable HTTP/2, if available
|
||||||
|
Protocols h2 http/1.1
|
||||||
|
|
||||||
|
# HTTP Strict Transport Security (mod_headers is required) (63072000 seconds)
|
||||||
|
Header always set Strict-Transport-Security "max-age=63072000"
|
||||||
|
|
||||||
# configure SSL
|
# configure SSL
|
||||||
SSLEngine on
|
SSLEngine on
|
||||||
SSLCertificateFile /etc/letsencrypt/live/HUB.DOMAIN.TLD/fullchain.pem
|
SSLCertificateFile /etc/letsencrypt/live/HUB.DOMAIN.TLD/fullchain.pem
|
||||||
SSLCertificateKeyFile /etc/letsencrypt/live/HUB.DOMAIN.TLD/privkey.pem
|
SSLCertificateKeyFile /etc/letsencrypt/live/HUB.DOMAIN.TLD/privkey.pem
|
||||||
SSLProtocol All -SSLv2 -SSLv3
|
|
||||||
SSLOpenSSLConfCmd DHParameters /etc/ssl/certs/dhparam.pem
|
SSLOpenSSLConfCmd DHParameters /etc/ssl/certs/dhparam.pem
|
||||||
SSLCipherSuite EECDH+AESGCM:EDH+AESGCM:AES256+EECDH:AES256+EDH
|
|
||||||
|
# intermediate configuration from ssl-config.mozilla.org (2022-03-03)
|
||||||
|
# Please note, that this configuration might be out-dated - please update it accordingly using https://ssl-config.mozilla.org/
|
||||||
|
SSLProtocol all -SSLv3 -TLSv1 -TLSv1.1
|
||||||
|
SSLCipherSuite ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384
|
||||||
|
SSLHonorCipherOrder off
|
||||||
|
SSLSessionTickets off
|
||||||
|
|
||||||
# Use RewriteEngine to handle websocket connection upgrades
|
# Use RewriteEngine to handle websocket connection upgrades
|
||||||
RewriteEngine On
|
RewriteEngine On
|
||||||
@@ -208,6 +219,7 @@ Listen 443
|
|||||||
# proxy to JupyterHub
|
# proxy to JupyterHub
|
||||||
ProxyPass http://127.0.0.1:8000/
|
ProxyPass http://127.0.0.1:8000/
|
||||||
ProxyPassReverse http://127.0.0.1:8000/
|
ProxyPassReverse http://127.0.0.1:8000/
|
||||||
|
RequestHeader set "X-Forwarded-Proto" expr=%{REQUEST_SCHEME}
|
||||||
</Location>
|
</Location>
|
||||||
</VirtualHost>
|
</VirtualHost>
|
||||||
```
|
```
|
||||||
@@ -219,8 +231,8 @@ In case of the need to run the jupyterhub under /jhub/ or other location please
|
|||||||
httpd.conf amendments:
|
httpd.conf amendments:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
RewriteRule /jhub/(.*) ws://127.0.0.1:8000/jhub/$1 [NE.P,L]
|
RewriteRule /jhub/(.*) ws://127.0.0.1:8000/jhub/$1 [P,L]
|
||||||
RewriteRule /jhub/(.*) http://127.0.0.1:8000/jhub/$1 [NE,P,L]
|
RewriteRule /jhub/(.*) http://127.0.0.1:8000/jhub/$1 [P,L]
|
||||||
|
|
||||||
ProxyPass /jhub/ http://127.0.0.1:8000/jhub/
|
ProxyPass /jhub/ http://127.0.0.1:8000/jhub/
|
||||||
ProxyPassReverse /jhub/ http://127.0.0.1:8000/jhub/
|
ProxyPassReverse /jhub/ http://127.0.0.1:8000/jhub/
|
||||||
|
@@ -76,13 +76,26 @@ c.InteractiveShellApp.extensions.append("cython")
|
|||||||
|
|
||||||
### Example: Enable a Jupyter notebook configuration setting for all users
|
### Example: Enable a Jupyter notebook configuration setting for all users
|
||||||
|
|
||||||
|
:::{note}
|
||||||
|
These examples configure the Jupyter ServerApp,
|
||||||
|
which is used by JupyterLab, the default in JupyterHub 2.0.
|
||||||
|
|
||||||
|
If you are using the classing Jupyter Notebook server,
|
||||||
|
the same things should work,
|
||||||
|
with the following substitutions:
|
||||||
|
|
||||||
|
- Where you see `jupyter_server_config`, use `jupyter_notebook_config`
|
||||||
|
- Where you see `NotebookApp`, use `ServerApp`
|
||||||
|
|
||||||
|
:::
|
||||||
|
|
||||||
To enable Jupyter notebook's internal idle-shutdown behavior (requires
|
To enable Jupyter notebook's internal idle-shutdown behavior (requires
|
||||||
notebook ≥ 5.4), set the following in the `/etc/jupyter/jupyter_notebook_config.py`
|
notebook ≥ 5.4), set the following in the `/etc/jupyter/jupyter_server_config.py`
|
||||||
file:
|
file:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# shutdown the server after no activity for an hour
|
# shutdown the server after no activity for an hour
|
||||||
c.NotebookApp.shutdown_no_activity_timeout = 60 * 60
|
c.ServerApp.shutdown_no_activity_timeout = 60 * 60
|
||||||
# shutdown kernels after no activity for 20 minutes
|
# shutdown kernels after no activity for 20 minutes
|
||||||
c.MappingKernelManager.cull_idle_timeout = 20 * 60
|
c.MappingKernelManager.cull_idle_timeout = 20 * 60
|
||||||
# check for idle kernels every two minutes
|
# check for idle kernels every two minutes
|
||||||
@@ -112,8 +125,8 @@ Assuming I have a Python 2 and Python 3 environment that I want to make
|
|||||||
sure are available, I can install their specs system-wide (in /usr/local) with:
|
sure are available, I can install their specs system-wide (in /usr/local) with:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
/path/to/python3 -m IPython kernel install --prefix=/usr/local
|
/path/to/python3 -m ipykernel install --prefix=/usr/local
|
||||||
/path/to/python2 -m IPython kernel install --prefix=/usr/local
|
/path/to/python2 -m ipykernel install --prefix=/usr/local
|
||||||
```
|
```
|
||||||
|
|
||||||
## Multi-user hosts vs. Containers
|
## Multi-user hosts vs. Containers
|
||||||
@@ -176,12 +189,40 @@ The number of named servers per user can be limited by setting
|
|||||||
c.JupyterHub.named_server_limit_per_user = 5
|
c.JupyterHub.named_server_limit_per_user = 5
|
||||||
```
|
```
|
||||||
|
|
||||||
## Switching to Jupyter Server
|
(classic-notebook-ui)=
|
||||||
|
|
||||||
[Jupyter Server](https://jupyter-server.readthedocs.io/en/latest/) is a new Tornado Server backend for Jupyter web applications (e.g. JupyterLab 3.0 uses this package as its default backend).
|
## Switching back to classic notebook
|
||||||
|
|
||||||
By default, the single-user notebook server uses the (old) `NotebookApp` from the [notebook](https://github.com/jupyter/notebook) package. You can switch to using Jupyter Server's `ServerApp` backend (this will likely become the default in future releases) by setting the `JUPYTERHUB_SINGLEUSER_APP` environment variable to:
|
By default the single-user server launches JupyterLab,
|
||||||
|
which is based on [Jupyter Server][].
|
||||||
|
This is the default server when running JupyterHub ≥ 2.0.
|
||||||
|
You can switch to using the legacy Jupyter Notebook server by setting the `JUPYTERHUB_SINGLEUSER_APP` environment variable
|
||||||
|
(in the single-user environment) to:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export JUPYTERHUB_SINGLEUSER_APP='notebook.notebookapp.NotebookApp'
|
||||||
|
```
|
||||||
|
|
||||||
|
[jupyter server]: https://jupyter-server.readthedocs.io
|
||||||
|
[jupyter notebook]: https://jupyter-notebook.readthedocs.io
|
||||||
|
|
||||||
|
:::{versionchanged} 2.0
|
||||||
|
JupyterLab is now the default singleuser UI, if available,
|
||||||
|
which is based on the [Jupyter Server][],
|
||||||
|
no longer the legacy [Jupyter Notebook][] server.
|
||||||
|
JupyterHub prior to 2.0 launched the legacy notebook server (`jupyter notebook`),
|
||||||
|
and Jupyter server could be selected by specifying
|
||||||
|
|
||||||
|
```python
|
||||||
|
# jupyterhub_config.py
|
||||||
|
c.Spawner.cmd = ["jupyter-labhub"]
|
||||||
|
```
|
||||||
|
|
||||||
|
or for an otherwise customized Jupyter Server app,
|
||||||
|
set the environment variable:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export JUPYTERHUB_SINGLEUSER_APP='jupyter_server.serverapp.ServerApp'
|
export JUPYTERHUB_SINGLEUSER_APP='jupyter_server.serverapp.ServerApp'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
:::
|
||||||
|
@@ -16,10 +16,12 @@ what happens under-the-hood when you deploy and configure your JupyterHub.
|
|||||||
proxy
|
proxy
|
||||||
separate-proxy
|
separate-proxy
|
||||||
rest
|
rest
|
||||||
|
rest-api
|
||||||
server-api
|
server-api
|
||||||
monitoring
|
monitoring
|
||||||
database
|
database
|
||||||
templates
|
templates
|
||||||
|
api-only
|
||||||
../events/index
|
../events/index
|
||||||
config-user-env
|
config-user-env
|
||||||
config-examples
|
config-examples
|
||||||
|
27
docs/source/reference/rest-api.md
Normal file
27
docs/source/reference/rest-api.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# JupyterHub REST API
|
||||||
|
|
||||||
|
Below is an interactive view of JupyterHub's OpenAPI specification.
|
||||||
|
|
||||||
|
<!-- client-rendered openapi UI copied from FastAPI -->
|
||||||
|
|
||||||
|
<link type="text/css" rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@3/swagger-ui.css">
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@4.1/swagger-ui-bundle.js"></script>
|
||||||
|
<!-- `SwaggerUIBundle` is now available on the page -->
|
||||||
|
|
||||||
|
<!-- render the ui here -->
|
||||||
|
<div id="openapi-ui"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const ui = SwaggerUIBundle({
|
||||||
|
url: '../_static/rest-api.yml',
|
||||||
|
dom_id: '#openapi-ui',
|
||||||
|
presets: [
|
||||||
|
SwaggerUIBundle.presets.apis,
|
||||||
|
SwaggerUIBundle.SwaggerUIStandalonePreset
|
||||||
|
],
|
||||||
|
layout: "BaseLayout",
|
||||||
|
deepLinking: true,
|
||||||
|
showExtensions: true,
|
||||||
|
showCommonExtensions: true,
|
||||||
|
});
|
||||||
|
</script>
|
@@ -1,14 +0,0 @@
|
|||||||
:orphan:
|
|
||||||
|
|
||||||
===================
|
|
||||||
JupyterHub REST API
|
|
||||||
===================
|
|
||||||
|
|
||||||
.. this doc exists as a resolvable link target
|
|
||||||
.. which _static files are not
|
|
||||||
|
|
||||||
.. meta::
|
|
||||||
:http-equiv=refresh: 0;url=../_static/rest-api/index.html
|
|
||||||
|
|
||||||
The rest API docs are `here <../_static/rest-api/index.html>`_
|
|
||||||
if you are not redirected automatically.
|
|
@@ -1,3 +1,5 @@
|
|||||||
|
(rest-api)=
|
||||||
|
|
||||||
# Using JupyterHub's REST API
|
# Using JupyterHub's REST API
|
||||||
|
|
||||||
This section will give you information on:
|
This section will give you information on:
|
||||||
@@ -111,7 +113,6 @@ c.JupyterHub.load_roles = [
|
|||||||
"scopes": [
|
"scopes": [
|
||||||
# specify the permissions the token should have
|
# specify the permissions the token should have
|
||||||
"admin:users",
|
"admin:users",
|
||||||
"admin:services",
|
|
||||||
],
|
],
|
||||||
"services": [
|
"services": [
|
||||||
# assign the service the above permissions
|
# assign the service the above permissions
|
||||||
@@ -302,12 +303,8 @@ or kubernetes pods.
|
|||||||
|
|
||||||
## Learn more about the API
|
## Learn more about the API
|
||||||
|
|
||||||
You can see the full [JupyterHub REST API][] for details. This REST API Spec can
|
You can see the full [JupyterHub REST API][] for details.
|
||||||
be viewed in a more [interactive style on swagger's petstore][].
|
|
||||||
Both resources contain the same information and differ only in its display.
|
|
||||||
Note: The Swagger specification is being renamed the [OpenAPI Initiative][].
|
|
||||||
|
|
||||||
[interactive style on swagger's petstore]: https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/jupyterhub/jupyterhub/HEAD/docs/rest-api.yml#!/default
|
|
||||||
[openapi initiative]: https://www.openapis.org/
|
[openapi initiative]: https://www.openapis.org/
|
||||||
[jupyterhub rest api]: ./rest-api
|
[jupyterhub rest api]: ./rest-api
|
||||||
[jupyter notebook rest api]: https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/jupyter/notebook/HEAD/notebook/services/api/api.yaml
|
[jupyter notebook rest api]: https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/jupyter/notebook/HEAD/notebook/services/api/api.yaml
|
||||||
|
@@ -1,17 +1,5 @@
|
|||||||
# Services
|
# Services
|
||||||
|
|
||||||
With version 0.7, JupyterHub adds support for **Services**.
|
|
||||||
|
|
||||||
This section provides the following information about Services:
|
|
||||||
|
|
||||||
- [Definition of a Service](#definition-of-a-service)
|
|
||||||
- [Properties of a Service](#properties-of-a-service)
|
|
||||||
- [Hub-Managed Services](#hub-managed-services)
|
|
||||||
- [Launching a Hub-Managed Service](#launching-a-hub-managed-service)
|
|
||||||
- [Externally-Managed Services](#externally-managed-services)
|
|
||||||
- [Writing your own Services](#writing-your-own-services)
|
|
||||||
- [Hub Authentication and Services](#hub-authentication-and-services)
|
|
||||||
|
|
||||||
## Definition of a Service
|
## Definition of a Service
|
||||||
|
|
||||||
When working with JupyterHub, a **Service** is defined as a process that interacts
|
When working with JupyterHub, a **Service** is defined as a process that interacts
|
||||||
@@ -47,6 +35,8 @@ A Service may have the following properties:
|
|||||||
the service will be added to the proxy at `/services/:name`
|
the service will be added to the proxy at `/services/:name`
|
||||||
- `api_token: str (default - None)` - For Externally-Managed Services you need to specify
|
- `api_token: str (default - None)` - For Externally-Managed Services you need to specify
|
||||||
an API token to perform API requests to the Hub
|
an API token to perform API requests to the Hub
|
||||||
|
- `display: bool (default - True)` - When set to true, display a link to the
|
||||||
|
service's URL under the 'Services' dropdown in user's hub home page.
|
||||||
|
|
||||||
If a service is also to be managed by the Hub, it has a few extra options:
|
If a service is also to be managed by the Hub, it has a few extra options:
|
||||||
|
|
||||||
@@ -95,6 +85,7 @@ c.JupyterHub.load_roles = [
|
|||||||
# 'admin:users' # needed if culling idle users as well
|
# 'admin:users' # needed if culling idle users as well
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
]
|
||||||
|
|
||||||
c.JupyterHub.services = [
|
c.JupyterHub.services = [
|
||||||
{
|
{
|
||||||
@@ -115,6 +106,8 @@ parameters, which describe the environment needed to start the Service process:
|
|||||||
|
|
||||||
The Hub will pass the following environment variables to launch the Service:
|
The Hub will pass the following environment variables to launch the Service:
|
||||||
|
|
||||||
|
(service-env)=
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
JUPYTERHUB_SERVICE_NAME: The name of the service
|
JUPYTERHUB_SERVICE_NAME: The name of the service
|
||||||
JUPYTERHUB_API_TOKEN: API token assigned to the service
|
JUPYTERHUB_API_TOKEN: API token assigned to the service
|
||||||
@@ -123,7 +116,10 @@ JUPYTERHUB_BASE_URL: Base URL of the Hub (https://mydomain[:port]/)
|
|||||||
JUPYTERHUB_SERVICE_PREFIX: URL path prefix of this service (/services/:service-name/)
|
JUPYTERHUB_SERVICE_PREFIX: URL path prefix of this service (/services/:service-name/)
|
||||||
JUPYTERHUB_SERVICE_URL: Local URL where the service is expected to be listening.
|
JUPYTERHUB_SERVICE_URL: Local URL where the service is expected to be listening.
|
||||||
Only for proxied web services.
|
Only for proxied web services.
|
||||||
JUPYTERHUB_OAUTH_SCOPES: JSON-serialized list of scopes to use for allowing access to the service.
|
JUPYTERHUB_OAUTH_SCOPES: JSON-serialized list of scopes to use for allowing access to the service
|
||||||
|
(deprecated in 3.0, use JUPYTERHUB_OAUTH_ACCESS_SCOPES).
|
||||||
|
JUPYTERHUB_OAUTH_ACCESS_SCOPES: JSON-serialized list of scopes to use for allowing access to the service (new in 3.0).
|
||||||
|
JUPYTERHUB_OAUTH_CLIENT_ALLOWED_SCOPES: JSON-serialized list of scopes that can be requested by the oauth client on behalf of users (new in 3.0).
|
||||||
```
|
```
|
||||||
|
|
||||||
For the previous 'cull idle' Service example, these environment variables
|
For the previous 'cull idle' Service example, these environment variables
|
||||||
@@ -196,25 +192,45 @@ extra slash you might get unexpected behavior. For example if your service has a
|
|||||||
|
|
||||||
## Hub Authentication and Services
|
## Hub Authentication and Services
|
||||||
|
|
||||||
JupyterHub 0.7 introduces some utilities for using the Hub's authentication
|
JupyterHub provides some utilities for using the Hub's authentication
|
||||||
mechanism to govern access to your service. When a user logs into JupyterHub,
|
mechanism to govern access to your service.
|
||||||
the Hub sets a **cookie (`jupyterhub-services`)**. The service can use this
|
|
||||||
cookie to authenticate requests.
|
|
||||||
|
|
||||||
JupyterHub ships with a reference implementation of Hub authentication that
|
Requests to all JupyterHub services are made with OAuth tokens.
|
||||||
|
These can either be requests with a token in the `Authorization` header,
|
||||||
|
or url parameter `?token=...`,
|
||||||
|
or browser requests which must complete the OAuth authorization code flow,
|
||||||
|
which results in a token that should be persisted for future requests
|
||||||
|
(persistence is up to the service,
|
||||||
|
but an encrypted cookie confined to the service path is appropriate,
|
||||||
|
and provided by default).
|
||||||
|
|
||||||
|
:::{versionchanged} 2.0
|
||||||
|
The shared `jupyterhub-services` cookie is removed.
|
||||||
|
OAuth must be used to authenticate browser requests with services.
|
||||||
|
:::
|
||||||
|
|
||||||
|
JupyterHub includes a reference implementation of Hub authentication that
|
||||||
can be used by services. You may go beyond this reference implementation and
|
can be used by services. You may go beyond this reference implementation and
|
||||||
create custom hub-authenticating clients and services. We describe the process
|
create custom hub-authenticating clients and services. We describe the process
|
||||||
below.
|
below.
|
||||||
|
|
||||||
The reference, or base, implementation is the [`HubAuth`][hubauth] class,
|
The reference, or base, implementation is the {class}`.HubAuth` class,
|
||||||
which implements the requests to the Hub.
|
which implements the API requests to the Hub that resolve a token to a User model.
|
||||||
|
|
||||||
|
There are two levels of authentication with the Hub:
|
||||||
|
|
||||||
|
- {class}`.HubAuth` - the most basic authentication,
|
||||||
|
for services that should only accept API requests authorized with a token.
|
||||||
|
|
||||||
|
- {class}`.HubOAuth` - For services that should use oauth to authenticate with the Hub.
|
||||||
|
This should be used for any service that serves pages that should be visited with a browser.
|
||||||
|
|
||||||
To use HubAuth, you must set the `.api_token`, either programmatically when constructing the class,
|
To use HubAuth, you must set the `.api_token`, either programmatically when constructing the class,
|
||||||
or via the `JUPYTERHUB_API_TOKEN` environment variable.
|
or via the `JUPYTERHUB_API_TOKEN` environment variable.
|
||||||
|
|
||||||
Most of the logic for authentication implementation is found in the
|
Most of the logic for authentication implementation is found in the
|
||||||
[`HubAuth.user_for_token`][hubauth.user_for_token]
|
{meth}`.HubAuth.user_for_token` methods,
|
||||||
methods, which makes a request of the Hub, and returns:
|
which makes a request of the Hub, and returns:
|
||||||
|
|
||||||
- None, if no user could be identified, or
|
- None, if no user could be identified, or
|
||||||
- a dict of the following form:
|
- a dict of the following form:
|
||||||
@@ -235,6 +251,19 @@ action.
|
|||||||
HubAuth also caches the Hub's response for a number of seconds,
|
HubAuth also caches the Hub's response for a number of seconds,
|
||||||
configurable by the `cookie_cache_max_age` setting (default: five minutes).
|
configurable by the `cookie_cache_max_age` setting (default: five minutes).
|
||||||
|
|
||||||
|
If your service would like to make further requests _on behalf of users_,
|
||||||
|
it should use the token issued by this OAuth process.
|
||||||
|
If you are using tornado,
|
||||||
|
you can access the token authenticating the current request with {meth}`.HubAuth.get_token`.
|
||||||
|
|
||||||
|
:::{versionchanged} 2.2
|
||||||
|
|
||||||
|
{meth}`.HubAuth.get_token` adds support for retrieving
|
||||||
|
tokens stored in tornado cookies after completion of OAuth.
|
||||||
|
Previously, it only retrieved tokens from URL parameters or the Authorization header.
|
||||||
|
Passing `get_token(handler, in_cookie=False)` preserves this behavior.
|
||||||
|
:::
|
||||||
|
|
||||||
### Flask Example
|
### Flask Example
|
||||||
|
|
||||||
For example, you have a Flask service that returns information about a user.
|
For example, you have a Flask service that returns information about a user.
|
||||||
@@ -250,18 +279,17 @@ for more details.
|
|||||||
### Authenticating tornado services with JupyterHub
|
### Authenticating tornado services with JupyterHub
|
||||||
|
|
||||||
Since most Jupyter services are written with tornado,
|
Since most Jupyter services are written with tornado,
|
||||||
we include a mixin class, [`HubAuthenticated`][hubauthenticated],
|
we include a mixin class, [`HubOAuthenticated`][huboauthenticated],
|
||||||
for quickly authenticating your own tornado services with JupyterHub.
|
for quickly authenticating your own tornado services with JupyterHub.
|
||||||
|
|
||||||
Tornado's `@web.authenticated` method calls a Handler's `.get_current_user`
|
Tornado's {py:func}`~.tornado.web.authenticated` decorator calls a Handler's {py:meth}`~.tornado.web.RequestHandler.get_current_user`
|
||||||
method to identify the user. Mixing in `HubAuthenticated` defines
|
method to identify the user. Mixing in {class}`.HubAuthenticated` defines
|
||||||
`get_current_user` to use HubAuth. If you want to configure the HubAuth
|
{meth}`~.HubAuthenticated.get_current_user` to use HubAuth. If you want to configure the HubAuth
|
||||||
instance beyond the default, you'll want to define an `initialize` method,
|
instance beyond the default, you'll want to define an {py:meth}`~.tornado.web.RequestHandler.initialize` method,
|
||||||
such as:
|
such as:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
class MyHandler(HubAuthenticated, web.RequestHandler):
|
class MyHandler(HubOAuthenticated, web.RequestHandler):
|
||||||
hub_users = {'inara', 'mal'}
|
|
||||||
|
|
||||||
def initialize(self, hub_auth):
|
def initialize(self, hub_auth):
|
||||||
self.hub_auth = hub_auth
|
self.hub_auth = hub_auth
|
||||||
@@ -271,14 +299,21 @@ class MyHandler(HubAuthenticated, web.RequestHandler):
|
|||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
The HubAuth will automatically load the desired configuration from the Service
|
The HubAuth class will automatically load the desired configuration from the Service
|
||||||
environment variables.
|
[environment variables](service-env).
|
||||||
|
|
||||||
If you want to limit user access, you can specify allowed users through either the
|
:::{versionchanged} 2.0
|
||||||
`.hub_users` attribute or `.hub_groups`. These are sets that check against the
|
|
||||||
username and user group list, respectively. If a user matches neither the user
|
Access scopes are used to govern access to services.
|
||||||
list nor the group list, they will not be allowed access. If both are left
|
Prior to 2.0,
|
||||||
undefined, then any user will be allowed.
|
sets of users and groups could be used to grant access
|
||||||
|
by defining `.hub_groups` or `.hub_users` on the authenticated handler.
|
||||||
|
These are ignored if the 2.0 `.hub_scopes` is defined.
|
||||||
|
:::
|
||||||
|
|
||||||
|
:::{seealso}
|
||||||
|
{meth}`.HubAuth.check_scopes`
|
||||||
|
:::
|
||||||
|
|
||||||
### Implementing your own Authentication with JupyterHub
|
### Implementing your own Authentication with JupyterHub
|
||||||
|
|
||||||
@@ -344,7 +379,7 @@ The `scopes` field can be used to manage access.
|
|||||||
Note: a user will have access to a service to complete oauth access to the service for the first time.
|
Note: a user will have access to a service to complete oauth access to the service for the first time.
|
||||||
Individual permissions may be revoked at any later point without revoking the token,
|
Individual permissions may be revoked at any later point without revoking the token,
|
||||||
in which case the `scopes` field in this model should be checked on each access.
|
in which case the `scopes` field in this model should be checked on each access.
|
||||||
The default required scopes for access are available from `hub_auth.oauth_scopes` or `$JUPYTERHUB_OAUTH_SCOPES`.
|
The default required scopes for access are available from `hub_auth.oauth_scopes` or `$JUPYTERHUB_OAUTH_ACCESS_SCOPES`.
|
||||||
|
|
||||||
An example of using an Externally-Managed Service and authentication is
|
An example of using an Externally-Managed Service and authentication is
|
||||||
in [nbviewer README][nbviewer example] section on securing the notebook viewer,
|
in [nbviewer README][nbviewer example] section on securing the notebook viewer,
|
||||||
@@ -354,9 +389,6 @@ section on securing the notebook viewer.
|
|||||||
|
|
||||||
[requests]: http://docs.python-requests.org/en/master/
|
[requests]: http://docs.python-requests.org/en/master/
|
||||||
[services_auth]: ../api/services.auth.html
|
[services_auth]: ../api/services.auth.html
|
||||||
[huboauth]: ../api/services.auth.html#jupyterhub.services.auth.HubOAuth
|
|
||||||
[hubauth.user_for_token]: ../api/services.auth.html#jupyterhub.services.auth.HubAuth.user_for_token
|
|
||||||
[hubauthenticated]: ../api/services.auth.html#jupyterhub.services.auth.HubAuthenticated
|
|
||||||
[nbviewer example]: https://github.com/jupyter/nbviewer#securing-the-notebook-viewer
|
[nbviewer example]: https://github.com/jupyter/nbviewer#securing-the-notebook-viewer
|
||||||
[fastapi example]: https://github.com/jupyterhub/jupyterhub/tree/HEAD/examples/service-fastapi
|
[fastapi example]: https://github.com/jupyterhub/jupyterhub/tree/HEAD/examples/service-fastapi
|
||||||
[fastapi]: https://fastapi.tiangolo.com
|
[fastapi]: https://fastapi.tiangolo.com
|
||||||
|
@@ -108,6 +108,16 @@ class MySpawner(Spawner):
|
|||||||
return url
|
return url
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Exception handling
|
||||||
|
|
||||||
|
When `Spawner.start` raises an Exception, a message can be passed on to the user via the exception via a `.jupyterhub_html_message` or `.jupyterhub_message` attribute.
|
||||||
|
|
||||||
|
When the Exception has a `.jupyterhub_html_message` attribute, it will be rendered as HTML to the user.
|
||||||
|
|
||||||
|
Alternatively `.jupyterhub_message` is rendered as unformatted text.
|
||||||
|
|
||||||
|
If both attributes are not present, the Exception will be shown to the user as unformatted text.
|
||||||
|
|
||||||
### Spawner.poll
|
### Spawner.poll
|
||||||
|
|
||||||
`Spawner.poll` should check if the spawner is still running.
|
`Spawner.poll` should check if the spawner is still running.
|
||||||
@@ -298,6 +308,9 @@ The process environment is returned by `Spawner.get_env`, which specifies the fo
|
|||||||
This is also the OAuth client secret.
|
This is also the OAuth client secret.
|
||||||
- JUPYTERHUB_CLIENT_ID - the OAuth client ID for authenticating visitors.
|
- JUPYTERHUB_CLIENT_ID - the OAuth client ID for authenticating visitors.
|
||||||
- JUPYTERHUB_OAUTH_CALLBACK_URL - the callback URL to use in oauth, typically `/user/:name/oauth_callback`
|
- JUPYTERHUB_OAUTH_CALLBACK_URL - the callback URL to use in oauth, typically `/user/:name/oauth_callback`
|
||||||
|
- JUPYTERHUB_OAUTH_ACCESS_SCOPES - the scopes required to access the server (called JUPYTERHUB_OAUTH_SCOPES prior to 3.0)
|
||||||
|
- JUPYTERHUB_OAUTH_CLIENT_ALLOWED_SCOPES - the scopes the service is allowed to request.
|
||||||
|
If no scopes are requested explicitly, these scopes will be requested.
|
||||||
|
|
||||||
Optional environment variables, depending on configuration:
|
Optional environment variables, depending on configuration:
|
||||||
|
|
||||||
|
@@ -275,7 +275,7 @@ where `ssl_cert` is example-chained.crt and ssl_key to your private key.
|
|||||||
|
|
||||||
Then restart JupyterHub.
|
Then restart JupyterHub.
|
||||||
|
|
||||||
See also [JupyterHub SSL encryption](./getting-started/security-basics.html#ssl-encryption).
|
See also {ref}`ssl-encryption`.
|
||||||
|
|
||||||
### Install JupyterHub without a network connection
|
### Install JupyterHub without a network connection
|
||||||
|
|
||||||
@@ -371,7 +371,7 @@ a JupyterHub deployment. The commands are:
|
|||||||
- System and deployment information
|
- System and deployment information
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
jupyter troubleshooting
|
jupyter troubleshoot
|
||||||
```
|
```
|
||||||
|
|
||||||
- Kernel information
|
- Kernel information
|
||||||
|
46
docs/test_docs.py
Normal file
46
docs/test_docs.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from subprocess import run
|
||||||
|
|
||||||
|
from ruamel.yaml import YAML
|
||||||
|
|
||||||
|
yaml = YAML(typ="safe")
|
||||||
|
|
||||||
|
here = Path(__file__).absolute().parent
|
||||||
|
root = here.parent
|
||||||
|
|
||||||
|
|
||||||
|
def test_rest_api_version_is_updated():
|
||||||
|
"""Checks that the version in JupyterHub's REST API definition file
|
||||||
|
(rest-api.yml) is matching the JupyterHub version."""
|
||||||
|
version_py = root.joinpath("jupyterhub", "_version.py")
|
||||||
|
rest_api_yaml = root.joinpath("docs", "source", "_static", "rest-api.yml")
|
||||||
|
ns = {}
|
||||||
|
with version_py.open() as f:
|
||||||
|
exec(f.read(), {}, ns)
|
||||||
|
jupyterhub_version = ns["__version__"]
|
||||||
|
|
||||||
|
with rest_api_yaml.open() as f:
|
||||||
|
rest_api = yaml.load(f)
|
||||||
|
rest_api_version = rest_api["info"]["version"]
|
||||||
|
|
||||||
|
assert jupyterhub_version == rest_api_version
|
||||||
|
|
||||||
|
|
||||||
|
def test_rest_api_rbac_scope_descriptions_are_updated():
|
||||||
|
"""Checks that the RBAC scope descriptions in JupyterHub's REST API
|
||||||
|
definition file (rest-api.yml) as can be updated by generate-scope-table.py
|
||||||
|
matches what is committed."""
|
||||||
|
run([sys.executable, "source/rbac/generate-scope-table.py"], cwd=here, check=True)
|
||||||
|
run(
|
||||||
|
[
|
||||||
|
"git",
|
||||||
|
"--no-pager",
|
||||||
|
"diff",
|
||||||
|
"--color=always",
|
||||||
|
"--exit-code",
|
||||||
|
str(here.joinpath("source", "_static", "rest-api.yml")),
|
||||||
|
],
|
||||||
|
cwd=here,
|
||||||
|
check=True,
|
||||||
|
)
|
31
examples/azuread-with-group-management/jupyterhub_config.py
Normal file
31
examples/azuread-with-group-management/jupyterhub_config.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"""sample jupyterhub config file for testing
|
||||||
|
|
||||||
|
configures jupyterhub with dummyauthenticator and simplespawner
|
||||||
|
to enable testing without administrative privileges.
|
||||||
|
"""
|
||||||
|
|
||||||
|
c = get_config() # noqa
|
||||||
|
c.Application.log_level = 'DEBUG'
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from oauthenticator.azuread import AzureAdOAuthenticator
|
||||||
|
|
||||||
|
c.JupyterHub.authenticator_class = AzureAdOAuthenticator
|
||||||
|
|
||||||
|
c.AzureAdOAuthenticator.client_id = os.getenv("AAD_CLIENT_ID")
|
||||||
|
c.AzureAdOAuthenticator.client_secret = os.getenv("AAD_CLIENT_SECRET")
|
||||||
|
c.AzureAdOAuthenticator.oauth_callback_url = os.getenv("AAD_CALLBACK_URL")
|
||||||
|
c.AzureAdOAuthenticator.tenant_id = os.getenv("AAD_TENANT_ID")
|
||||||
|
c.AzureAdOAuthenticator.username_claim = "email"
|
||||||
|
c.AzureAdOAuthenticator.authorize_url = os.getenv("AAD_AUTHORIZE_URL")
|
||||||
|
c.AzureAdOAuthenticator.token_url = os.getenv("AAD_TOKEN_URL")
|
||||||
|
c.Authenticator.manage_groups = True
|
||||||
|
c.Authenticator.refresh_pre_spawn = True
|
||||||
|
|
||||||
|
# Optionally set a global password that all users must use
|
||||||
|
# c.DummyAuthenticator.password = "your_password"
|
||||||
|
|
||||||
|
from jupyterhub.spawner import SimpleLocalProcessSpawner
|
||||||
|
|
||||||
|
c.JupyterHub.spawner_class = SimpleLocalProcessSpawner
|
2
examples/azuread-with-group-management/requirements.txt
Normal file
2
examples/azuread-with-group-management/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
oauthenticator
|
||||||
|
pyjwt
|
132
examples/custom-scopes/grades.py
Normal file
132
examples/custom-scopes/grades.py
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import os
|
||||||
|
from functools import wraps
|
||||||
|
from html import escape
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from tornado.httpserver import HTTPServer
|
||||||
|
from tornado.ioloop import IOLoop
|
||||||
|
from tornado.web import Application, RequestHandler, authenticated
|
||||||
|
|
||||||
|
from jupyterhub.services.auth import HubOAuthCallbackHandler, HubOAuthenticated
|
||||||
|
from jupyterhub.utils import url_path_join
|
||||||
|
|
||||||
|
SCOPE_PREFIX = "custom:grades"
|
||||||
|
READ_SCOPE = f"{SCOPE_PREFIX}:read"
|
||||||
|
WRITE_SCOPE = f"{SCOPE_PREFIX}:write"
|
||||||
|
|
||||||
|
|
||||||
|
def require_scope(scopes):
|
||||||
|
"""Decorator to require scopes
|
||||||
|
|
||||||
|
For use if multiple methods on one Handler
|
||||||
|
may want different scopes,
|
||||||
|
so class-level .hub_scopes is insufficient
|
||||||
|
(e.g. read for GET, write for POST).
|
||||||
|
"""
|
||||||
|
if isinstance(scopes, str):
|
||||||
|
scopes = [scopes]
|
||||||
|
|
||||||
|
def wrap(method):
|
||||||
|
"""The actual decorator"""
|
||||||
|
|
||||||
|
@wraps(method)
|
||||||
|
@authenticated
|
||||||
|
def wrapped(self, *args, **kwargs):
|
||||||
|
self.hub_scopes = scopes
|
||||||
|
return method(self, *args, **kwargs)
|
||||||
|
|
||||||
|
return wrapped
|
||||||
|
|
||||||
|
return wrap
|
||||||
|
|
||||||
|
|
||||||
|
class MyGradesHandler(HubOAuthenticated, RequestHandler):
|
||||||
|
# no hub_scopes, anyone with access to this service
|
||||||
|
# will be able to visit this URL
|
||||||
|
|
||||||
|
@authenticated
|
||||||
|
def get(self):
|
||||||
|
self.write("<h1>My grade</h1>")
|
||||||
|
name = self.current_user["name"]
|
||||||
|
grades = self.settings["grades"]
|
||||||
|
self.write(f"<p>My name is: {escape(name)}</p>")
|
||||||
|
if name in grades:
|
||||||
|
self.write(f"<p>My grade is: {escape(str(grades[name]))}</p>")
|
||||||
|
else:
|
||||||
|
self.write("<p>No grade entered</p>")
|
||||||
|
if READ_SCOPE in self.current_user["scopes"]:
|
||||||
|
self.write('<a href="grades/">enter grades</a>')
|
||||||
|
|
||||||
|
|
||||||
|
class GradesHandler(HubOAuthenticated, RequestHandler):
|
||||||
|
# default scope for this Handler: read-only
|
||||||
|
hub_scopes = [READ_SCOPE]
|
||||||
|
|
||||||
|
def _render(self):
|
||||||
|
grades = self.settings["grades"]
|
||||||
|
self.write("<h1>All grades</h1>")
|
||||||
|
self.write("<table>")
|
||||||
|
self.write("<tr><th>Student</th><th>Grade</th></tr>")
|
||||||
|
for student, grade in grades.items():
|
||||||
|
qstudent = escape(student)
|
||||||
|
qgrade = escape(str(grade))
|
||||||
|
self.write(
|
||||||
|
f"""
|
||||||
|
<tr>
|
||||||
|
<td class="student">{qstudent}</td>
|
||||||
|
<td class="grade">{qgrade}</td>
|
||||||
|
</tr>
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
if WRITE_SCOPE in self.current_user["scopes"]:
|
||||||
|
self.write("Enter grade:")
|
||||||
|
self.write(
|
||||||
|
"""
|
||||||
|
<form action=. method=POST>
|
||||||
|
<input name=student placeholder=student></input>
|
||||||
|
<input kind=number name=grade placeholder=grade></input>
|
||||||
|
<input type="submit" value="Submit">
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
@require_scope([READ_SCOPE])
|
||||||
|
async def get(self):
|
||||||
|
self._render()
|
||||||
|
|
||||||
|
# POST requires WRITE_SCOPE instead of READ_SCOPE
|
||||||
|
@require_scope([WRITE_SCOPE])
|
||||||
|
async def post(self):
|
||||||
|
name = self.get_argument("student")
|
||||||
|
grade = self.get_argument("grade")
|
||||||
|
self.settings["grades"][name] = grade
|
||||||
|
self._render()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
base_url = os.environ['JUPYTERHUB_SERVICE_PREFIX']
|
||||||
|
|
||||||
|
app = Application(
|
||||||
|
[
|
||||||
|
(base_url, MyGradesHandler),
|
||||||
|
(url_path_join(base_url, 'grades/'), GradesHandler),
|
||||||
|
(
|
||||||
|
url_path_join(base_url, 'oauth_callback'),
|
||||||
|
HubOAuthCallbackHandler,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
cookie_secret=os.urandom(32),
|
||||||
|
grades={"student": 53},
|
||||||
|
)
|
||||||
|
|
||||||
|
http_server = HTTPServer(app)
|
||||||
|
url = urlparse(os.environ['JUPYTERHUB_SERVICE_URL'])
|
||||||
|
|
||||||
|
http_server.listen(url.port, url.hostname)
|
||||||
|
try:
|
||||||
|
IOLoop.current().start()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
52
examples/custom-scopes/jupyterhub_config.py
Normal file
52
examples/custom-scopes/jupyterhub_config.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import sys
|
||||||
|
|
||||||
|
c = get_config() # noqa
|
||||||
|
|
||||||
|
c.JupyterHub.services = [
|
||||||
|
{
|
||||||
|
'name': 'grades',
|
||||||
|
'url': 'http://127.0.0.1:10101',
|
||||||
|
'command': [sys.executable, './grades.py'],
|
||||||
|
'oauth_client_allowed_scopes': [
|
||||||
|
'custom:grades:write',
|
||||||
|
'custom:grades:read',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
c.JupyterHub.custom_scopes = {
|
||||||
|
"custom:grades:read": {
|
||||||
|
"description": "read-access to all grades",
|
||||||
|
},
|
||||||
|
"custom:grades:write": {
|
||||||
|
"description": "Enter new grades",
|
||||||
|
"subscopes": ["custom:grades:read"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JupyterHub.load_roles = [
|
||||||
|
{
|
||||||
|
"name": "user",
|
||||||
|
# grant all users access to services
|
||||||
|
"scopes": ["access:services", "self"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "grader",
|
||||||
|
# grant graders access to write grades
|
||||||
|
"scopes": ["custom:grades:write"],
|
||||||
|
"users": ["grader"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "instructor",
|
||||||
|
# grant instructors access to read, but not write grades
|
||||||
|
"scopes": ["custom:grades:read"],
|
||||||
|
"users": ["instructor"],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
c.JupyterHub.allowed_users = {"instructor", "grader", "student"}
|
||||||
|
# dummy spawner and authenticator for testing, don't actually use these!
|
||||||
|
c.JupyterHub.authenticator_class = 'dummy'
|
||||||
|
c.JupyterHub.spawner_class = 'simple'
|
||||||
|
c.JupyterHub.ip = '127.0.0.1' # let's just run on localhost while dummy auth is enabled
|
||||||
|
c.JupyterHub.log_level = 10
|
@@ -5,13 +5,10 @@ so all URLs and requests necessary for OAuth with JupyterHub should be in one pl
|
|||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode, urlparse
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
from tornado import log
|
from tornado import log, web
|
||||||
from tornado import web
|
from tornado.httpclient import AsyncHTTPClient, HTTPRequest
|
||||||
from tornado.httpclient import AsyncHTTPClient
|
|
||||||
from tornado.httpclient import HTTPRequest
|
|
||||||
from tornado.httputil import url_concat
|
from tornado.httputil import url_concat
|
||||||
from tornado.ioloop import IOLoop
|
from tornado.ioloop import IOLoop
|
||||||
|
|
||||||
|
@@ -2,6 +2,8 @@
|
|||||||
# 1. start/stop servers, and
|
# 1. start/stop servers, and
|
||||||
# 2. access the server API
|
# 2. access the server API
|
||||||
|
|
||||||
|
c = get_config() # noqa
|
||||||
|
|
||||||
c.JupyterHub.load_roles = [
|
c.JupyterHub.load_roles = [
|
||||||
{
|
{
|
||||||
"name": "launcher",
|
"name": "launcher",
|
||||||
|
@@ -16,7 +16,6 @@ import time
|
|||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -29,7 +28,7 @@ def get_token():
|
|||||||
token_file = here.joinpath("service-token")
|
token_file = here.joinpath("service-token")
|
||||||
log.info(f"Loading token from {token_file}")
|
log.info(f"Loading token from {token_file}")
|
||||||
with token_file.open("r") as f:
|
with token_file.open("r") as f:
|
||||||
token = f.read()
|
token = f.read().strip()
|
||||||
return token
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
@@ -3,9 +3,7 @@ import datetime
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from tornado import escape
|
from tornado import escape, ioloop, web
|
||||||
from tornado import ioloop
|
|
||||||
from tornado import web
|
|
||||||
|
|
||||||
from jupyterhub.services.auth import HubAuthenticated
|
from jupyterhub.services.auth import HubAuthenticated
|
||||||
|
|
||||||
|
@@ -1,8 +1,5 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any, Dict, List, Optional
|
||||||
from typing import Dict
|
|
||||||
from typing import List
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
@@ -1,9 +1,7 @@
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException, Security, status
|
||||||
from fastapi import Security
|
|
||||||
from fastapi import status
|
|
||||||
from fastapi.security import OAuth2AuthorizationCodeBearer
|
from fastapi.security import OAuth2AuthorizationCodeBearer
|
||||||
from fastapi.security.api_key import APIKeyQuery
|
from fastapi.security.api_key import APIKeyQuery
|
||||||
|
|
||||||
|
@@ -1,14 +1,9 @@
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter, Depends, Form, Request
|
||||||
from fastapi import Depends
|
|
||||||
from fastapi import Form
|
|
||||||
from fastapi import Request
|
|
||||||
|
|
||||||
from .client import get_client
|
from .client import get_client
|
||||||
from .models import AuthorizationError
|
from .models import AuthorizationError, HubApiError, User
|
||||||
from .models import HubApiError
|
|
||||||
from .models import User
|
|
||||||
from .security import get_current_user
|
from .security import get_current_user
|
||||||
|
|
||||||
# APIRouter prefix cannot end in /
|
# APIRouter prefix cannot end in /
|
||||||
|
@@ -7,16 +7,10 @@ import os
|
|||||||
import secrets
|
import secrets
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
|
||||||
from flask import Flask
|
from flask import Flask, Response, make_response, redirect, request, session
|
||||||
from flask import make_response
|
|
||||||
from flask import redirect
|
|
||||||
from flask import request
|
|
||||||
from flask import Response
|
|
||||||
from flask import session
|
|
||||||
|
|
||||||
from jupyterhub.services.auth import HubOAuth
|
from jupyterhub.services.auth import HubOAuth
|
||||||
|
|
||||||
|
|
||||||
prefix = os.environ.get('JUPYTERHUB_SERVICE_PREFIX', '/')
|
prefix = os.environ.get('JUPYTERHUB_SERVICE_PREFIX', '/')
|
||||||
|
|
||||||
auth = HubOAuth(api_token=os.environ['JUPYTERHUB_API_TOKEN'], cache_max_age=60)
|
auth = HubOAuth(api_token=os.environ['JUPYTERHUB_API_TOKEN'], cache_max_age=60)
|
||||||
|
@@ -8,59 +8,72 @@ There is an implementation each of api-token-based `HubAuthenticated` and OAuth-
|
|||||||
|
|
||||||
1. Launch JupyterHub and the `whoami` services with
|
1. Launch JupyterHub and the `whoami` services with
|
||||||
|
|
||||||
jupyterhub --ip=127.0.0.1
|
jupyterhub
|
||||||
|
|
||||||
2. Visit http://127.0.0.1:8000/services/whoami-oauth
|
2. Visit http://127.0.0.1:8000/services/whoami-oauth
|
||||||
|
|
||||||
After logging in with your local-system credentials, you should see a JSON dump of your user info:
|
After logging in with any username and password, you should see a JSON dump of your user info:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"admin": false,
|
"admin": false,
|
||||||
"last_activity": "2016-05-27T14:05:18.016372",
|
"groups": [],
|
||||||
|
"kind": "user",
|
||||||
"name": "queequeg",
|
"name": "queequeg",
|
||||||
"pending": null,
|
"scopes": ["access:services!service=whoami-oauth"],
|
||||||
"server": "/user/queequeg"
|
"session_id": "5a2164273a7346728873bcc2e3c26415"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
What is contained in the model will depend on the permissions
|
||||||
|
requested in the `oauth_client_allowed_scopes` configuration of the service `whoami-oauth` service.
|
||||||
|
The default is the minimum required for identification and access to the service,
|
||||||
|
which will provide the username and current scopes.
|
||||||
|
|
||||||
The `whoami-api` service powered by the base `HubAuthenticated` class only supports token-authenticated API requests,
|
The `whoami-api` service powered by the base `HubAuthenticated` class only supports token-authenticated API requests,
|
||||||
not browser visits, because it does not implement OAuth. Visit it by requesting an api token from the tokens page,
|
not browser visits, because it does not implement OAuth. Visit it by requesting an api token from the tokens page (`/hub/token`),
|
||||||
and making a direct request:
|
and making a direct request:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
$ curl -H "Authorization: token 8630bbd8ef064c48b22c7f122f0cd8ad" http://127.0.0.1:8000/services/whoami-api/ | jq .
|
token="d584cbc5bba2430fb153aadb305029b4"
|
||||||
|
curl -H "Authorization: token $token" http://127.0.0.1:8000/services/whoami-api/ | jq .
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
{
|
{
|
||||||
"admin": false,
|
"admin": false,
|
||||||
"created": "2021-05-21T09:47:41.299400Z",
|
"created": "2021-12-20T09:49:37.258427Z",
|
||||||
"groups": [],
|
"groups": [],
|
||||||
"kind": "user",
|
"kind": "user",
|
||||||
"last_activity": "2021-05-21T09:49:08.290745Z",
|
"last_activity": "2021-12-20T10:07:31.298056Z",
|
||||||
"name": "test",
|
"name": "queequeg",
|
||||||
"pending": null,
|
"pending": null,
|
||||||
"roles": [
|
"roles": ["user"],
|
||||||
"user"
|
|
||||||
],
|
|
||||||
"scopes": [
|
"scopes": [
|
||||||
|
"access:servers!user=queequeg",
|
||||||
"access:services",
|
"access:services",
|
||||||
"access:servers!user=test",
|
"delete:servers!user=queequeg",
|
||||||
"read:users!user=test",
|
"read:servers!user=queequeg",
|
||||||
"read:users:activity!user=test",
|
"read:tokens!user=queequeg",
|
||||||
"read:users:groups!user=test",
|
"read:users!user=queequeg",
|
||||||
"read:users:name!user=test",
|
"read:users:activity!user=queequeg",
|
||||||
"read:servers!user=test",
|
"read:users:groups!user=queequeg",
|
||||||
"read:tokens!user=test",
|
"read:users:name!user=queequeg",
|
||||||
"users!user=test",
|
"servers!user=queequeg",
|
||||||
"users:activity!user=test",
|
"tokens!user=queequeg",
|
||||||
"users:groups!user=test",
|
"users:activity!user=queequeg"
|
||||||
"users:name!user=test",
|
|
||||||
"servers!user=test",
|
|
||||||
"tokens!user=test"
|
|
||||||
],
|
],
|
||||||
"server": null
|
"server": null,
|
||||||
|
"servers": {},
|
||||||
|
"session_id": null
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The above is a more complete user model than the `whoami-oauth` example, because
|
||||||
|
the token was issued with the default `token` role,
|
||||||
|
which has the `inherit` metascope,
|
||||||
|
meaning the token has access to everything the tokens owner has access to.
|
||||||
|
|
||||||
This relies on the Hub starting the whoami services, via config (see [jupyterhub_config.py](./jupyterhub_config.py)).
|
This relies on the Hub starting the whoami services, via config (see [jupyterhub_config.py](./jupyterhub_config.py)).
|
||||||
|
|
||||||
To govern access to the services, create **roles** with the scope `access:services!service=$service-name`,
|
To govern access to the services, create **roles** with the scope `access:services!service=$service-name`,
|
||||||
|
@@ -10,7 +10,15 @@ c.JupyterHub.services = [
|
|||||||
'name': 'whoami-oauth',
|
'name': 'whoami-oauth',
|
||||||
'url': 'http://127.0.0.1:10102',
|
'url': 'http://127.0.0.1:10102',
|
||||||
'command': [sys.executable, './whoami-oauth.py'],
|
'command': [sys.executable, './whoami-oauth.py'],
|
||||||
'oauth_roles': ['user'],
|
# the default oauth roles is minimal,
|
||||||
|
# only requesting access to the service,
|
||||||
|
# and identification by name,
|
||||||
|
# nothing more.
|
||||||
|
# Specifying 'oauth_client_allowed_scopes' as a list of scopes
|
||||||
|
# allows requesting more information about users,
|
||||||
|
# or the ability to take actions on users' behalf, as required.
|
||||||
|
# the 'inherit' scope means the full permissions of the owner
|
||||||
|
# 'oauth_client_allowed_scopes': ['inherit'],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@@ -10,12 +10,9 @@ from urllib.parse import urlparse
|
|||||||
|
|
||||||
from tornado.httpserver import HTTPServer
|
from tornado.httpserver import HTTPServer
|
||||||
from tornado.ioloop import IOLoop
|
from tornado.ioloop import IOLoop
|
||||||
from tornado.web import Application
|
from tornado.web import Application, RequestHandler, authenticated
|
||||||
from tornado.web import authenticated
|
|
||||||
from tornado.web import RequestHandler
|
|
||||||
|
|
||||||
from jupyterhub.services.auth import HubOAuthCallbackHandler
|
from jupyterhub.services.auth import HubOAuthCallbackHandler, HubOAuthenticated
|
||||||
from jupyterhub.services.auth import HubOAuthenticated
|
|
||||||
from jupyterhub.utils import url_path_join
|
from jupyterhub.utils import url_path_join
|
||||||
|
|
||||||
|
|
||||||
|
@@ -10,9 +10,7 @@ from urllib.parse import urlparse
|
|||||||
|
|
||||||
from tornado.httpserver import HTTPServer
|
from tornado.httpserver import HTTPServer
|
||||||
from tornado.ioloop import IOLoop
|
from tornado.ioloop import IOLoop
|
||||||
from tornado.web import Application
|
from tornado.web import Application, RequestHandler, authenticated
|
||||||
from tornado.web import authenticated
|
|
||||||
from tornado.web import RequestHandler
|
|
||||||
|
|
||||||
from jupyterhub.services.auth import HubAuthenticated
|
from jupyterhub.services.auth import HubAuthenticated
|
||||||
|
|
||||||
|
@@ -1,47 +0,0 @@
|
|||||||
/*
|
|
||||||
object-assign
|
|
||||||
(c) Sindre Sorhus
|
|
||||||
@license MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
/*!
|
|
||||||
Copyright (c) 2017 Jed Watson.
|
|
||||||
Licensed under the MIT License (MIT), see
|
|
||||||
http://jedwatson.github.io/classnames
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** @license React v0.20.1
|
|
||||||
* scheduler.production.min.js
|
|
||||||
*
|
|
||||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
|
||||||
*
|
|
||||||
* This source code is licensed under the MIT license found in the
|
|
||||||
* LICENSE file in the root directory of this source tree.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** @license React v16.13.1
|
|
||||||
* react-is.production.min.js
|
|
||||||
*
|
|
||||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
|
||||||
*
|
|
||||||
* This source code is licensed under the MIT license found in the
|
|
||||||
* LICENSE file in the root directory of this source tree.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** @license React v17.0.1
|
|
||||||
* react-dom.production.min.js
|
|
||||||
*
|
|
||||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
|
||||||
*
|
|
||||||
* This source code is licensed under the MIT license found in the
|
|
||||||
* LICENSE file in the root directory of this source tree.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** @license React v17.0.1
|
|
||||||
* react.production.min.js
|
|
||||||
*
|
|
||||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
|
||||||
*
|
|
||||||
* This source code is licensed under the MIT license found in the
|
|
||||||
* LICENSE file in the root directory of this source tree.
|
|
||||||
*/
|
|
@@ -1,6 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<head></head>
|
|
||||||
<body>
|
|
||||||
<div id="admin-react-hook"></div>
|
|
||||||
<script src="admin-react.js"></script>
|
|
||||||
</body>
|
|
@@ -8,7 +8,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "yarn && webpack",
|
"build": "yarn && webpack",
|
||||||
"hot": "webpack && webpack-dev-server",
|
"hot": "webpack && webpack-dev-server",
|
||||||
"place": "cp -r build/admin-react.js ../share/jupyterhub/static/js/admin-react.js",
|
"place": "cp build/admin-react.js* ../share/jupyterhub/static/js/",
|
||||||
"test": "jest --verbose",
|
"test": "jest --verbose",
|
||||||
"snap": "jest --updateSnapshot",
|
"snap": "jest --updateSnapshot",
|
||||||
"lint": "eslint --ext .jsx --ext .js src/",
|
"lint": "eslint --ext .jsx --ext .js src/",
|
||||||
@@ -28,40 +28,48 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/core": "^7.12.3",
|
|
||||||
"@babel/preset-env": "^7.12.11",
|
|
||||||
"@babel/preset-react": "^7.12.10",
|
|
||||||
"babel-loader": "^8.2.1",
|
|
||||||
"bootstrap": "^4.5.3",
|
"bootstrap": "^4.5.3",
|
||||||
"css-loader": "^5.0.1",
|
|
||||||
"eslint-plugin-unused-imports": "^1.1.1",
|
|
||||||
"file-loader": "^6.2.0",
|
|
||||||
"history": "^5.0.0",
|
"history": "^5.0.0",
|
||||||
|
"lodash.debounce": "^4.0.8",
|
||||||
"prop-types": "^15.7.2",
|
"prop-types": "^15.7.2",
|
||||||
"react": "^17.0.1",
|
"react": "^17.0.1",
|
||||||
"react-bootstrap": "^1.4.0",
|
"react-bootstrap": "^2.1.1",
|
||||||
"react-dom": "^17.0.1",
|
"react-dom": "^17.0.1",
|
||||||
"react-icons": "^4.1.0",
|
"react-icons": "^4.1.0",
|
||||||
"react-multi-select-component": "^3.0.7",
|
"react-multi-select-component": "^3.0.7",
|
||||||
|
"react-object-table-viewer": "^1.0.7",
|
||||||
"react-redux": "^7.2.2",
|
"react-redux": "^7.2.2",
|
||||||
"react-router": "^5.2.0",
|
"react-router": "^5.2.0",
|
||||||
"react-router-dom": "^5.2.0",
|
"react-router-dom": "^5.2.0",
|
||||||
"recompose": "^0.30.0",
|
"recompose": "npm:react-recompose@^0.31.2",
|
||||||
"redux": "^4.0.5",
|
"redux": "^4.0.5",
|
||||||
"style-loader": "^2.0.0",
|
"regenerator-runtime": "^0.13.9"
|
||||||
"webpack": "^5.6.0",
|
|
||||||
"webpack-cli": "^3.3.4",
|
|
||||||
"webpack-dev-server": "^3.11.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@wojtekmaj/enzyme-adapter-react-17": "^0.4.1",
|
"@babel/core": "^7.12.3",
|
||||||
|
"@babel/preset-env": "^7.12.11",
|
||||||
|
"@babel/preset-react": "^7.12.10",
|
||||||
|
"@testing-library/jest-dom": "^5.15.1",
|
||||||
|
"@testing-library/react": "^12.1.2",
|
||||||
|
"@testing-library/user-event": "^13.5.0",
|
||||||
|
"@webpack-cli/serve": "^1.7.0",
|
||||||
|
"@wojtekmaj/enzyme-adapter-react-17": "^0.6.5",
|
||||||
"babel-jest": "^26.6.3",
|
"babel-jest": "^26.6.3",
|
||||||
|
"babel-loader": "^8.2.1",
|
||||||
|
"css-loader": "^5.0.1",
|
||||||
"enzyme": "^3.11.0",
|
"enzyme": "^3.11.0",
|
||||||
"eslint": "^7.18.0",
|
"eslint": "^7.18.0",
|
||||||
"eslint-plugin-prettier": "^3.3.1",
|
"eslint-plugin-prettier": "^3.3.1",
|
||||||
"eslint-plugin-react": "^7.22.0",
|
"eslint-plugin-react": "^7.22.0",
|
||||||
|
"eslint-plugin-unused-imports": "^1.1.1",
|
||||||
|
"file-loader": "^6.2.0",
|
||||||
"identity-obj-proxy": "^3.0.0",
|
"identity-obj-proxy": "^3.0.0",
|
||||||
"jest": "^26.6.3",
|
"jest": "^26.6.3",
|
||||||
"prettier": "^2.2.1"
|
"prettier": "^2.2.1",
|
||||||
|
"sinon": "^13.0.1",
|
||||||
|
"style-loader": "^2.0.0",
|
||||||
|
"webpack": "^5.6.0",
|
||||||
|
"webpack-cli": "^4.10.0",
|
||||||
|
"webpack-dev-server": "^4.9.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -22,15 +22,16 @@ const store = createStore(reducers, initialState);
|
|||||||
const App = () => {
|
const App = () => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let { limit, user_page, groups_page } = initialState;
|
let { limit, user_page, groups_page } = initialState;
|
||||||
jhapiRequest(`/users?offset=${user_page * limit}&limit=${limit}`, "GET")
|
let api = withAPI()().props;
|
||||||
.then((data) => data.json())
|
api
|
||||||
|
.updateUsers(user_page * limit, limit)
|
||||||
.then((data) =>
|
.then((data) =>
|
||||||
store.dispatch({ type: "USER_PAGE", value: { data: data, page: 0 } })
|
store.dispatch({ type: "USER_PAGE", value: { data: data, page: 0 } })
|
||||||
)
|
)
|
||||||
.catch((err) => console.log(err));
|
.catch((err) => console.log(err));
|
||||||
|
|
||||||
jhapiRequest(`/groups?offset=${groups_page * limit}&limit=${limit}`, "GET")
|
api
|
||||||
.then((data) => data.json())
|
.updateGroups(groups_page * limit, limit)
|
||||||
.then((data) =>
|
.then((data) =>
|
||||||
store.dispatch({ type: "GROUPS_PAGE", value: { data: data, page: 0 } })
|
store.dispatch({ type: "GROUPS_PAGE", value: { data: data, page: 0 } })
|
||||||
)
|
)
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
export const initialState = {
|
export const initialState = {
|
||||||
user_data: undefined,
|
user_data: undefined,
|
||||||
user_page: 0,
|
user_page: 0,
|
||||||
|
name_filter: "",
|
||||||
groups_data: undefined,
|
groups_data: undefined,
|
||||||
groups_page: 0,
|
groups_page: 0,
|
||||||
limit: window.api_page_limit,
|
limit: window.api_page_limit,
|
||||||
@@ -13,6 +14,7 @@ export const reducers = (state = initialState, action) => {
|
|||||||
return Object.assign({}, state, {
|
return Object.assign({}, state, {
|
||||||
user_page: action.value.page,
|
user_page: action.value.page,
|
||||||
user_data: action.value.data,
|
user_data: action.value.data,
|
||||||
|
name_filter: action.value.name_filter || "",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Updates the client group model data and stores the page
|
// Updates the client group model data and stores the page
|
||||||
|
@@ -25,11 +25,20 @@ const AddUser = (props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="container">
|
<div className="container" data-testid="container">
|
||||||
{errorAlert != null ? (
|
{errorAlert != null ? (
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2">
|
<div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2">
|
||||||
<div className="alert alert-danger">{errorAlert}</div>
|
<div className="alert alert-danger">
|
||||||
|
{errorAlert}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="close"
|
||||||
|
onClick={() => setErrorAlert(null)}
|
||||||
|
>
|
||||||
|
<span>×</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -49,18 +58,23 @@ const AddUser = (props) => {
|
|||||||
id="add-user-textarea"
|
id="add-user-textarea"
|
||||||
rows="3"
|
rows="3"
|
||||||
placeholder="usernames separated by line"
|
placeholder="usernames separated by line"
|
||||||
|
data-testid="user-textarea"
|
||||||
onBlur={(e) => {
|
onBlur={(e) => {
|
||||||
let split_users = e.target.value.split("\n");
|
let split_users = e.target.value
|
||||||
|
.split("\n")
|
||||||
|
.map((u) => u.trim())
|
||||||
|
.filter((u) => u.length > 0);
|
||||||
setUsers(split_users);
|
setUsers(split_users);
|
||||||
}}
|
}}
|
||||||
></textarea>
|
></textarea>
|
||||||
<br></br>
|
<br></br>
|
||||||
<input
|
<input
|
||||||
className="form-check-input"
|
className="form-check-input"
|
||||||
|
data-testid="check"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
value=""
|
|
||||||
id="admin-check"
|
id="admin-check"
|
||||||
onChange={(e) => setAdmin(e.target.checked)}
|
checked={admin}
|
||||||
|
onChange={() => setAdmin(!admin)}
|
||||||
/>
|
/>
|
||||||
<span> </span>
|
<span> </span>
|
||||||
<label className="form-check-label">Admin</label>
|
<label className="form-check-label">Admin</label>
|
||||||
@@ -74,32 +88,25 @@ const AddUser = (props) => {
|
|||||||
<span> </span>
|
<span> </span>
|
||||||
<button
|
<button
|
||||||
id="submit"
|
id="submit"
|
||||||
|
data-testid="submit"
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
let filtered_users = users.filter(
|
addUsers(users, admin)
|
||||||
(e) =>
|
|
||||||
e.length > 2 &&
|
|
||||||
/[!@#$%^&*(),.?":{}|<>]/g.test(e) == false
|
|
||||||
);
|
|
||||||
if (filtered_users.length < users.length) {
|
|
||||||
setUsers(filtered_users);
|
|
||||||
failRegexEvent();
|
|
||||||
}
|
|
||||||
|
|
||||||
addUsers(filtered_users, admin)
|
|
||||||
.then((data) =>
|
.then((data) =>
|
||||||
data.status < 300
|
data.status < 300
|
||||||
? updateUsers(0, limit)
|
? updateUsers(0, limit)
|
||||||
.then((data) => dispatchPageChange(data, 0))
|
.then((data) => dispatchPageChange(data, 0))
|
||||||
.then(() => history.push("/"))
|
.then(() => history.push("/"))
|
||||||
.catch((err) => console.log(err))
|
.catch(() =>
|
||||||
|
setErrorAlert(`Failed to update users.`)
|
||||||
|
)
|
||||||
: setErrorAlert(
|
: setErrorAlert(
|
||||||
`[${data.status}] Failed to create user. ${
|
`Failed to create user. ${
|
||||||
data.status == 409 ? "User already exists." : ""
|
data.status == 409 ? "User already exists." : ""
|
||||||
}`
|
}`
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.catch((err) => console.log(err));
|
.catch(() => setErrorAlert(`Failed to create user.`));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Add Users
|
Add Users
|
||||||
|
@@ -1,12 +1,15 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import Enzyme, { mount } from "enzyme";
|
import "@testing-library/jest-dom";
|
||||||
import AddUser from "./AddUser";
|
import { act } from "react-dom/test-utils";
|
||||||
import Adapter from "@wojtekmaj/enzyme-adapter-react-17";
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
import { Provider, useDispatch, useSelector } from "react-redux";
|
import { Provider, useDispatch, useSelector } from "react-redux";
|
||||||
import { createStore } from "redux";
|
import { createStore } from "redux";
|
||||||
import { HashRouter } from "react-router-dom";
|
import { HashRouter } from "react-router-dom";
|
||||||
|
// eslint-disable-next-line
|
||||||
|
import regeneratorRuntime from "regenerator-runtime";
|
||||||
|
|
||||||
Enzyme.configure({ adapter: new Adapter() });
|
import AddUser from "./AddUser";
|
||||||
|
|
||||||
jest.mock("react-redux", () => ({
|
jest.mock("react-redux", () => ({
|
||||||
...jest.requireActual("react-redux"),
|
...jest.requireActual("react-redux"),
|
||||||
@@ -14,64 +17,123 @@ jest.mock("react-redux", () => ({
|
|||||||
useSelector: jest.fn(),
|
useSelector: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("AddUser Component: ", () => {
|
var mockAsync = (result) =>
|
||||||
var mockAsync = () =>
|
jest.fn().mockImplementation(() => Promise.resolve(result));
|
||||||
jest
|
|
||||||
.fn()
|
|
||||||
.mockImplementation(() => Promise.resolve({ key: "value", status: 200 }));
|
|
||||||
|
|
||||||
var addUserJsx = (callbackSpy) => (
|
var mockAsyncRejection = () =>
|
||||||
|
jest.fn().mockImplementation(() => Promise.reject());
|
||||||
|
|
||||||
|
var addUserJsx = (spy, spy2, spy3) => (
|
||||||
<Provider store={createStore(() => {}, {})}>
|
<Provider store={createStore(() => {}, {})}>
|
||||||
<HashRouter>
|
<HashRouter>
|
||||||
<AddUser
|
<AddUser
|
||||||
addUsers={callbackSpy}
|
addUsers={spy}
|
||||||
failRegexEvent={callbackSpy}
|
failRegexEvent={spy2 || spy}
|
||||||
updateUsers={callbackSpy}
|
updateUsers={spy3 || spy2 || spy}
|
||||||
history={{ push: () => {} }}
|
history={{ push: () => {} }}
|
||||||
/>
|
/>
|
||||||
</HashRouter>
|
</HashRouter>
|
||||||
</Provider>
|
</Provider>
|
||||||
);
|
);
|
||||||
|
|
||||||
var mockAppState = () => ({
|
var mockAppState = () => ({
|
||||||
limit: 3,
|
limit: 3,
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
useDispatch.mockImplementation(() => {
|
useDispatch.mockImplementation(() => {
|
||||||
return () => {};
|
return () => {};
|
||||||
});
|
});
|
||||||
useSelector.mockImplementation((callback) => {
|
useSelector.mockImplementation((callback) => {
|
||||||
return callback(mockAppState());
|
return callback(mockAppState());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
useDispatch.mockClear();
|
useDispatch.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Renders", () => {
|
test("Renders", async () => {
|
||||||
let component = mount(addUserJsx(mockAsync()));
|
await act(async () => {
|
||||||
expect(component.find(".container").length).toBe(1);
|
render(addUserJsx());
|
||||||
});
|
});
|
||||||
|
expect(screen.getByTestId("container")).toBeVisible();
|
||||||
it("Removes users when they fail Regex", () => {
|
});
|
||||||
let callbackSpy = mockAsync(),
|
|
||||||
component = mount(addUserJsx(callbackSpy)),
|
test("Removes users when they fail Regex", async () => {
|
||||||
textarea = component.find("textarea").first();
|
let callbackSpy = mockAsync();
|
||||||
textarea.simulate("blur", { target: { value: "foo\nbar\n!!*&*" } });
|
|
||||||
let submit = component.find("#submit");
|
await act(async () => {
|
||||||
submit.simulate("click");
|
render(addUserJsx(callbackSpy));
|
||||||
expect(callbackSpy).toHaveBeenCalledWith(["foo", "bar"], false);
|
});
|
||||||
});
|
|
||||||
|
let textarea = screen.getByTestId("user-textarea");
|
||||||
it("Correctly submits admin", () => {
|
let submit = screen.getByTestId("submit");
|
||||||
let callbackSpy = mockAsync(),
|
|
||||||
component = mount(addUserJsx(callbackSpy)),
|
fireEvent.blur(textarea, { target: { value: "foo \n bar\na@b.co\n \n\n" } });
|
||||||
input = component.find("input").first();
|
await act(async () => {
|
||||||
input.simulate("change", { target: { checked: true } });
|
fireEvent.click(submit);
|
||||||
let submit = component.find("#submit");
|
});
|
||||||
submit.simulate("click");
|
|
||||||
expect(callbackSpy).toHaveBeenCalledWith([], true);
|
expect(callbackSpy).toHaveBeenCalledWith(["foo", "bar", "a@b.co"], false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Correctly submits admin", async () => {
|
||||||
|
let callbackSpy = mockAsync();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
render(addUserJsx(callbackSpy));
|
||||||
|
});
|
||||||
|
|
||||||
|
let textarea = screen.getByTestId("user-textarea");
|
||||||
|
let submit = screen.getByTestId("submit");
|
||||||
|
let check = screen.getByTestId("check");
|
||||||
|
|
||||||
|
userEvent.click(check);
|
||||||
|
fireEvent.blur(textarea, { target: { value: "foo" } });
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(submit);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(callbackSpy).toHaveBeenCalledWith(["foo"], true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Shows a UI error dialogue when user creation fails", async () => {
|
||||||
|
let callbackSpy = mockAsyncRejection();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
render(addUserJsx(callbackSpy));
|
||||||
|
});
|
||||||
|
|
||||||
|
let submit = screen.getByTestId("submit");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(submit);
|
||||||
|
});
|
||||||
|
|
||||||
|
let errorDialog = screen.getByText("Failed to create user.");
|
||||||
|
|
||||||
|
expect(errorDialog).toBeVisible();
|
||||||
|
expect(callbackSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Shows a more specific UI error dialogue when user creation returns an improper status code", async () => {
|
||||||
|
let callbackSpy = mockAsync({ status: 409 });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
render(addUserJsx(callbackSpy));
|
||||||
|
});
|
||||||
|
|
||||||
|
let submit = screen.getByTestId("submit");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(submit);
|
||||||
|
});
|
||||||
|
|
||||||
|
let errorDialog = screen.getByText(
|
||||||
|
"Failed to create user. User already exists."
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(errorDialog).toBeVisible();
|
||||||
|
expect(callbackSpy).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
@@ -24,11 +24,20 @@ const CreateGroup = (props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="container">
|
<div className="container" data-testid="container">
|
||||||
{errorAlert != null ? (
|
{errorAlert != null ? (
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2">
|
<div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2">
|
||||||
<div className="alert alert-danger">{errorAlert}</div>
|
<div className="alert alert-danger">
|
||||||
|
{errorAlert}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="close"
|
||||||
|
onClick={() => setErrorAlert(null)}
|
||||||
|
>
|
||||||
|
<span>×</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -44,12 +53,13 @@ const CreateGroup = (props) => {
|
|||||||
<div className="input-group">
|
<div className="input-group">
|
||||||
<input
|
<input
|
||||||
className="group-name-input"
|
className="group-name-input"
|
||||||
|
data-testid="group-input"
|
||||||
type="text"
|
type="text"
|
||||||
id="group-name"
|
id="group-name"
|
||||||
value={groupName}
|
value={groupName}
|
||||||
placeholder="group name..."
|
placeholder="group name..."
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setGroupName(e.target.value);
|
setGroupName(e.target.value.trim());
|
||||||
}}
|
}}
|
||||||
></input>
|
></input>
|
||||||
</div>
|
</div>
|
||||||
@@ -61,6 +71,7 @@ const CreateGroup = (props) => {
|
|||||||
<span> </span>
|
<span> </span>
|
||||||
<button
|
<button
|
||||||
id="submit"
|
id="submit"
|
||||||
|
data-testid="submit"
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
createGroup(groupName)
|
createGroup(groupName)
|
||||||
@@ -69,16 +80,18 @@ const CreateGroup = (props) => {
|
|||||||
? updateGroups(0, limit)
|
? updateGroups(0, limit)
|
||||||
.then((data) => dispatchPageUpdate(data, 0))
|
.then((data) => dispatchPageUpdate(data, 0))
|
||||||
.then(() => history.push("/groups"))
|
.then(() => history.push("/groups"))
|
||||||
.catch((err) => console.log(err))
|
.catch(() =>
|
||||||
|
setErrorAlert(`Could not update groups list.`)
|
||||||
|
)
|
||||||
: setErrorAlert(
|
: setErrorAlert(
|
||||||
`[${data.status}] Failed to create group. ${
|
`Failed to create group. ${
|
||||||
data.status == 409
|
data.status == 409
|
||||||
? "Group already exists."
|
? "Group already exists."
|
||||||
: ""
|
: ""
|
||||||
}`
|
}`
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.catch((err) => console.log(err));
|
.catch(() => setErrorAlert(`Failed to create group.`));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Create
|
Create
|
||||||
|
@@ -1,13 +1,14 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import Enzyme, { mount } from "enzyme";
|
import "@testing-library/jest-dom";
|
||||||
import CreateGroup from "./CreateGroup";
|
import { act } from "react-dom/test-utils";
|
||||||
import Adapter from "@wojtekmaj/enzyme-adapter-react-17";
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
import { Provider, useDispatch, useSelector } from "react-redux";
|
import { Provider, useDispatch, useSelector } from "react-redux";
|
||||||
import { createStore } from "redux";
|
import { createStore } from "redux";
|
||||||
import { HashRouter } from "react-router-dom";
|
import { HashRouter } from "react-router-dom";
|
||||||
import regeneratorRuntime from "regenerator-runtime"; // eslint-disable-line
|
// eslint-disable-next-line
|
||||||
|
import regeneratorRuntime from "regenerator-runtime";
|
||||||
Enzyme.configure({ adapter: new Adapter() });
|
import CreateGroup from "./CreateGroup";
|
||||||
|
|
||||||
jest.mock("react-redux", () => ({
|
jest.mock("react-redux", () => ({
|
||||||
...jest.requireActual("react-redux"),
|
...jest.requireActual("react-redux"),
|
||||||
@@ -15,11 +16,13 @@ jest.mock("react-redux", () => ({
|
|||||||
useSelector: jest.fn(),
|
useSelector: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("CreateGroup Component: ", () => {
|
var mockAsync = (result) =>
|
||||||
var mockAsync = (result) =>
|
|
||||||
jest.fn().mockImplementation(() => Promise.resolve(result));
|
jest.fn().mockImplementation(() => Promise.resolve(result));
|
||||||
|
|
||||||
var createGroupJsx = (callbackSpy) => (
|
var mockAsyncRejection = () =>
|
||||||
|
jest.fn().mockImplementation(() => Promise.reject());
|
||||||
|
|
||||||
|
var createGroupJsx = (callbackSpy) => (
|
||||||
<Provider store={createStore(() => {}, {})}>
|
<Provider store={createStore(() => {}, {})}>
|
||||||
<HashRouter>
|
<HashRouter>
|
||||||
<CreateGroup
|
<CreateGroup
|
||||||
@@ -29,38 +32,84 @@ describe("CreateGroup Component: ", () => {
|
|||||||
/>
|
/>
|
||||||
</HashRouter>
|
</HashRouter>
|
||||||
</Provider>
|
</Provider>
|
||||||
);
|
);
|
||||||
|
|
||||||
var mockAppState = () => ({
|
var mockAppState = () => ({
|
||||||
limit: 3,
|
limit: 3,
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
useDispatch.mockImplementation(() => {
|
useDispatch.mockImplementation(() => {
|
||||||
return () => () => {};
|
return () => () => {};
|
||||||
});
|
});
|
||||||
useSelector.mockImplementation((callback) => {
|
useSelector.mockImplementation((callback) => {
|
||||||
return callback(mockAppState());
|
return callback(mockAppState());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
useDispatch.mockClear();
|
useDispatch.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Renders", () => {
|
test("Renders", async () => {
|
||||||
let component = mount(createGroupJsx());
|
await act(async () => {
|
||||||
expect(component.find(".container").length).toBe(1);
|
render(createGroupJsx());
|
||||||
});
|
});
|
||||||
|
expect(screen.getByTestId("container")).toBeVisible();
|
||||||
it("Calls createGroup on submit", () => {
|
});
|
||||||
let callbackSpy = mockAsync({ status: 200 }),
|
|
||||||
component = mount(createGroupJsx(callbackSpy)),
|
test("Calls createGroup on submit", async () => {
|
||||||
input = component.find("input").first(),
|
let callbackSpy = mockAsync({ status: 200 });
|
||||||
submit = component.find("#submit").first();
|
|
||||||
input.simulate("change", { target: { value: "" } });
|
await act(async () => {
|
||||||
submit.simulate("click");
|
render(createGroupJsx(callbackSpy));
|
||||||
expect(callbackSpy).toHaveBeenNthCalledWith(1, "");
|
});
|
||||||
expect(component.find(".alert.alert-danger").length).toBe(0);
|
|
||||||
});
|
let input = screen.getByTestId("group-input");
|
||||||
|
let submit = screen.getByTestId("submit");
|
||||||
|
|
||||||
|
userEvent.type(input, "groupname");
|
||||||
|
await act(async () => fireEvent.click(submit));
|
||||||
|
|
||||||
|
expect(callbackSpy).toHaveBeenNthCalledWith(1, "groupname");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Shows a UI error dialogue when group creation fails", async () => {
|
||||||
|
let callbackSpy = mockAsyncRejection();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
render(createGroupJsx(callbackSpy));
|
||||||
|
});
|
||||||
|
|
||||||
|
let submit = screen.getByTestId("submit");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(submit);
|
||||||
|
});
|
||||||
|
|
||||||
|
let errorDialog = screen.getByText("Failed to create group.");
|
||||||
|
|
||||||
|
expect(errorDialog).toBeVisible();
|
||||||
|
expect(callbackSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Shows a more specific UI error dialogue when user creation returns an improper status code", async () => {
|
||||||
|
let callbackSpy = mockAsync({ status: 409 });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
render(createGroupJsx(callbackSpy));
|
||||||
|
});
|
||||||
|
|
||||||
|
let submit = screen.getByTestId("submit");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(submit);
|
||||||
|
});
|
||||||
|
|
||||||
|
let errorDialog = screen.getByText(
|
||||||
|
"Failed to create group. Group already exists."
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(errorDialog).toBeVisible();
|
||||||
|
expect(callbackSpy).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
@@ -19,14 +19,7 @@ const EditUser = (props) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
var {
|
var { editUser, deleteUser, noChangeEvent, updateUsers, history } = props;
|
||||||
editUser,
|
|
||||||
deleteUser,
|
|
||||||
failRegexEvent,
|
|
||||||
noChangeEvent,
|
|
||||||
updateUsers,
|
|
||||||
history,
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
if (props.location.state == undefined) {
|
if (props.location.state == undefined) {
|
||||||
props.history.push("/");
|
props.history.push("/");
|
||||||
@@ -40,11 +33,20 @@ const EditUser = (props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="container">
|
<div className="container" data-testid="container">
|
||||||
{errorAlert != null ? (
|
{errorAlert != null ? (
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2">
|
<div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2">
|
||||||
<div className="alert alert-danger">{errorAlert}</div>
|
<div className="alert alert-danger">
|
||||||
|
{errorAlert}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="close"
|
||||||
|
onClick={() => setErrorAlert(null)}
|
||||||
|
>
|
||||||
|
<span>×</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -61,6 +63,7 @@ const EditUser = (props) => {
|
|||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<textarea
|
<textarea
|
||||||
className="form-control"
|
className="form-control"
|
||||||
|
data-testid="edit-username-input"
|
||||||
id="exampleFormControlTextarea1"
|
id="exampleFormControlTextarea1"
|
||||||
rows="3"
|
rows="3"
|
||||||
placeholder="updated username"
|
placeholder="updated username"
|
||||||
@@ -81,20 +84,26 @@ const EditUser = (props) => {
|
|||||||
<br></br>
|
<br></br>
|
||||||
<button
|
<button
|
||||||
id="delete-user"
|
id="delete-user"
|
||||||
|
data-testid="delete-user"
|
||||||
className="btn btn-danger btn-sm"
|
className="btn btn-danger btn-sm"
|
||||||
onClick={() => {
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
deleteUser(username)
|
deleteUser(username)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
data.status < 300
|
data.status < 300
|
||||||
? updateUsers(0, limit)
|
? updateUsers(0, limit)
|
||||||
.then((data) => dispatchPageChange(data, 0))
|
.then((data) => dispatchPageChange(data, 0))
|
||||||
.then(() => history.push("/"))
|
.then(() => history.push("/"))
|
||||||
.catch((err) => console.log(err))
|
.catch(() =>
|
||||||
: setErrorAlert(
|
setErrorAlert(
|
||||||
`[${data.status}] Failed to edit user.`
|
`Could not update users list.`
|
||||||
);
|
)
|
||||||
|
)
|
||||||
|
: setErrorAlert(`Failed to edit user.`);
|
||||||
})
|
})
|
||||||
.catch((err) => console.log(err));
|
.catch(() => {
|
||||||
|
setErrorAlert(`Failed to edit user.`);
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Delete user
|
Delete user
|
||||||
@@ -109,8 +118,10 @@ const EditUser = (props) => {
|
|||||||
<span> </span>
|
<span> </span>
|
||||||
<button
|
<button
|
||||||
id="submit"
|
id="submit"
|
||||||
|
data-testid="submit"
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
onClick={() => {
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
if (updatedUsername == "" && admin == has_admin) {
|
if (updatedUsername == "" && admin == has_admin) {
|
||||||
noChangeEvent();
|
noChangeEvent();
|
||||||
return;
|
return;
|
||||||
@@ -129,17 +140,20 @@ const EditUser = (props) => {
|
|||||||
? updateUsers(0, limit)
|
? updateUsers(0, limit)
|
||||||
.then((data) => dispatchPageChange(data, 0))
|
.then((data) => dispatchPageChange(data, 0))
|
||||||
.then(() => history.push("/"))
|
.then(() => history.push("/"))
|
||||||
.catch((err) => console.log(err))
|
.catch(() =>
|
||||||
: setErrorAlert(
|
setErrorAlert(
|
||||||
`[${data.status}] Failed to edit user.`
|
`Could not update users list.`
|
||||||
);
|
)
|
||||||
|
)
|
||||||
|
: setErrorAlert(`Failed to edit user.`);
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch(() => {
|
||||||
console.log(err);
|
setErrorAlert(`Failed to edit user.`);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setUpdatedUsername("");
|
setErrorAlert(
|
||||||
failRegexEvent();
|
`Failed to edit user. Make sure the username does not contain special characters.`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
editUser(username, username, admin)
|
editUser(username, username, admin)
|
||||||
@@ -148,13 +162,13 @@ const EditUser = (props) => {
|
|||||||
? updateUsers(0, limit)
|
? updateUsers(0, limit)
|
||||||
.then((data) => dispatchPageChange(data, 0))
|
.then((data) => dispatchPageChange(data, 0))
|
||||||
.then(() => history.push("/"))
|
.then(() => history.push("/"))
|
||||||
.catch((err) => console.log(err))
|
.catch(() =>
|
||||||
: setErrorAlert(
|
setErrorAlert(`Could not update users list.`)
|
||||||
`[${data.status}] Failed to edit user.`
|
)
|
||||||
);
|
: setErrorAlert(`Failed to edit user.`);
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch(() => {
|
||||||
console.log(err);
|
setErrorAlert(`Failed to edit user.`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
@@ -1,12 +1,14 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import Enzyme, { mount } from "enzyme";
|
import "@testing-library/jest-dom";
|
||||||
import EditUser from "./EditUser";
|
import { act } from "react-dom/test-utils";
|
||||||
import Adapter from "@wojtekmaj/enzyme-adapter-react-17";
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
import { Provider, useDispatch, useSelector } from "react-redux";
|
import { Provider, useDispatch, useSelector } from "react-redux";
|
||||||
import { createStore } from "redux";
|
import { createStore } from "redux";
|
||||||
import { HashRouter } from "react-router-dom";
|
import { HashRouter } from "react-router-dom";
|
||||||
|
// eslint-disable-next-line
|
||||||
|
import regeneratorRuntime from "regenerator-runtime";
|
||||||
|
|
||||||
Enzyme.configure({ adapter: new Adapter() });
|
import EditUser from "./EditUser";
|
||||||
|
|
||||||
jest.mock("react-redux", () => ({
|
jest.mock("react-redux", () => ({
|
||||||
...jest.requireActual("react-redux"),
|
...jest.requireActual("react-redux"),
|
||||||
@@ -14,20 +16,17 @@ jest.mock("react-redux", () => ({
|
|||||||
useSelector: jest.fn(),
|
useSelector: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("EditUser Component: ", () => {
|
var mockAsync = (data) =>
|
||||||
var mockAsync = () =>
|
jest.fn().mockImplementation(() => Promise.resolve(data));
|
||||||
jest
|
|
||||||
.fn()
|
|
||||||
.mockImplementation(() => Promise.resolve({ key: "value", status: 200 }));
|
|
||||||
var mockSync = () => jest.fn();
|
|
||||||
|
|
||||||
var editUserJsx = (callbackSpy, empty) => (
|
var mockAsyncRejection = () =>
|
||||||
|
jest.fn().mockImplementation(() => Promise.reject());
|
||||||
|
|
||||||
|
var editUserJsx = (callbackSpy, empty) => (
|
||||||
<Provider store={createStore(() => {}, {})}>
|
<Provider store={createStore(() => {}, {})}>
|
||||||
<HashRouter>
|
<HashRouter>
|
||||||
<EditUser
|
<EditUser
|
||||||
location={
|
location={empty ? {} : { state: { username: "foo", has_admin: false } }}
|
||||||
empty ? {} : { state: { username: "foo", has_admin: false } }
|
|
||||||
}
|
|
||||||
deleteUser={callbackSpy}
|
deleteUser={callbackSpy}
|
||||||
editUser={callbackSpy}
|
editUser={callbackSpy}
|
||||||
updateUsers={callbackSpy}
|
updateUsers={callbackSpy}
|
||||||
@@ -37,44 +36,104 @@ describe("EditUser Component: ", () => {
|
|||||||
/>
|
/>
|
||||||
</HashRouter>
|
</HashRouter>
|
||||||
</Provider>
|
</Provider>
|
||||||
);
|
);
|
||||||
|
|
||||||
var mockAppState = () => ({
|
var mockAppState = () => ({
|
||||||
limit: 3,
|
limit: 3,
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
useDispatch.mockImplementation(() => {
|
useDispatch.mockImplementation(() => {
|
||||||
return () => {};
|
return () => {};
|
||||||
});
|
});
|
||||||
useSelector.mockImplementation((callback) => {
|
useSelector.mockImplementation((callback) => {
|
||||||
return callback(mockAppState());
|
return callback(mockAppState());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
useDispatch.mockClear();
|
useDispatch.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Calls the delete user function when the button is pressed", () => {
|
test("Renders", async () => {
|
||||||
let callbackSpy = mockAsync(),
|
let callbackSpy = mockAsync({ key: "value", status: 200 });
|
||||||
component = mount(editUserJsx(callbackSpy)),
|
|
||||||
deleteUser = component.find("#delete-user");
|
await act(async () => {
|
||||||
deleteUser.simulate("click");
|
render(editUserJsx(callbackSpy));
|
||||||
expect(callbackSpy).toHaveBeenCalled();
|
});
|
||||||
});
|
|
||||||
|
expect(screen.getByTestId("container")).toBeVisible();
|
||||||
it("Submits the edits when the button is pressed", () => {
|
});
|
||||||
let callbackSpy = mockSync(),
|
|
||||||
component = mount(editUserJsx(callbackSpy)),
|
test("Calls the delete user function when the button is pressed", async () => {
|
||||||
submit = component.find("#submit");
|
let callbackSpy = mockAsync({ key: "value", status: 200 });
|
||||||
submit.simulate("click");
|
|
||||||
expect(callbackSpy).toHaveBeenCalled();
|
await act(async () => {
|
||||||
});
|
render(editUserJsx(callbackSpy));
|
||||||
|
});
|
||||||
it("Doesn't render when no data is provided", () => {
|
|
||||||
let callbackSpy = mockSync(),
|
let deleteUser = screen.getByTestId("delete-user");
|
||||||
component = mount(editUserJsx(callbackSpy, true));
|
|
||||||
expect(component.find(".container").length).toBe(0);
|
await act(async () => {
|
||||||
});
|
fireEvent.click(deleteUser);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(callbackSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Submits the edits when the button is pressed", async () => {
|
||||||
|
let callbackSpy = mockAsync({ key: "value", status: 200 });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
render(editUserJsx(callbackSpy));
|
||||||
|
});
|
||||||
|
|
||||||
|
let submit = screen.getByTestId("submit");
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(submit);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(callbackSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Shows a UI error dialogue when user edit fails", async () => {
|
||||||
|
let callbackSpy = mockAsyncRejection();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
render(editUserJsx(callbackSpy));
|
||||||
|
});
|
||||||
|
|
||||||
|
let submit = screen.getByTestId("submit");
|
||||||
|
let usernameInput = screen.getByTestId("edit-username-input");
|
||||||
|
|
||||||
|
fireEvent.blur(usernameInput, { target: { value: "whatever" } });
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(submit);
|
||||||
|
});
|
||||||
|
|
||||||
|
let errorDialog = screen.getByText("Failed to edit user.");
|
||||||
|
|
||||||
|
expect(errorDialog).toBeVisible();
|
||||||
|
expect(callbackSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Shows a UI error dialogue when user edit returns an improper status code", async () => {
|
||||||
|
let callbackSpy = mockAsync({ status: 409 });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
render(editUserJsx(callbackSpy));
|
||||||
|
});
|
||||||
|
|
||||||
|
let submit = screen.getByTestId("submit");
|
||||||
|
let usernameInput = screen.getByTestId("edit-username-input");
|
||||||
|
|
||||||
|
fireEvent.blur(usernameInput, { target: { value: "whatever" } });
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(submit);
|
||||||
|
});
|
||||||
|
|
||||||
|
let errorDialog = screen.getByText("Failed to edit user.");
|
||||||
|
|
||||||
|
expect(errorDialog).toBeVisible();
|
||||||
|
expect(callbackSpy).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
@@ -7,6 +7,7 @@ import GroupSelect from "../GroupSelect/GroupSelect";
|
|||||||
const GroupEdit = (props) => {
|
const GroupEdit = (props) => {
|
||||||
var [selected, setSelected] = useState([]),
|
var [selected, setSelected] = useState([]),
|
||||||
[changed, setChanged] = useState(false),
|
[changed, setChanged] = useState(false),
|
||||||
|
[errorAlert, setErrorAlert] = useState(null),
|
||||||
limit = useSelector((state) => state.limit);
|
limit = useSelector((state) => state.limit);
|
||||||
|
|
||||||
var dispatch = useDispatch();
|
var dispatch = useDispatch();
|
||||||
@@ -41,7 +42,25 @@ const GroupEdit = (props) => {
|
|||||||
if (!group_data) return <div></div>;
|
if (!group_data) return <div></div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container">
|
<div className="container" data-testid="container">
|
||||||
|
{errorAlert != null ? (
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2">
|
||||||
|
<div className="alert alert-danger">
|
||||||
|
{errorAlert}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="close"
|
||||||
|
onClick={() => setErrorAlert(null)}
|
||||||
|
>
|
||||||
|
<span>×</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<></>
|
||||||
|
)}
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2">
|
<div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2">
|
||||||
<h3>Editing Group {group_data.name}</h3>
|
<h3>Editing Group {group_data.name}</h3>
|
||||||
@@ -65,6 +84,7 @@ const GroupEdit = (props) => {
|
|||||||
<span> </span>
|
<span> </span>
|
||||||
<button
|
<button
|
||||||
id="submit"
|
id="submit"
|
||||||
|
data-testid="submit"
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// check for changes
|
// check for changes
|
||||||
@@ -89,29 +109,43 @@ const GroupEdit = (props) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
Promise.all(promiseQueue)
|
Promise.all(promiseQueue)
|
||||||
.then(() => {
|
.then((data) => {
|
||||||
updateGroups(0, limit)
|
// ensure status of all requests are < 300
|
||||||
|
let allPassed =
|
||||||
|
data.map((e) => e.status).filter((e) => e >= 300).length ==
|
||||||
|
0;
|
||||||
|
|
||||||
|
allPassed
|
||||||
|
? updateGroups(0, limit)
|
||||||
.then((data) => dispatchPageUpdate(data, 0))
|
.then((data) => dispatchPageUpdate(data, 0))
|
||||||
.then(() => history.push("/groups"));
|
.then(() => history.push("/groups"))
|
||||||
|
: setErrorAlert(`Failed to edit group.`);
|
||||||
})
|
})
|
||||||
.catch((err) => console.log(err));
|
.catch(() => {
|
||||||
|
console.log("outer");
|
||||||
|
setErrorAlert(`Failed to edit group.`);
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Apply
|
Apply
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
id="delete-group"
|
id="delete-group"
|
||||||
|
data-testid="delete-group"
|
||||||
className="btn btn-danger"
|
className="btn btn-danger"
|
||||||
style={{ float: "right" }}
|
style={{ float: "right" }}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
var groupName = group_data.name;
|
var groupName = group_data.name;
|
||||||
deleteGroup(groupName)
|
deleteGroup(groupName)
|
||||||
.then(() => {
|
// TODO add error if res not ok
|
||||||
updateGroups(0, limit)
|
.then((data) => {
|
||||||
|
data.status < 300
|
||||||
|
? updateGroups(0, limit)
|
||||||
.then((data) => dispatchPageUpdate(data, 0))
|
.then((data) => dispatchPageUpdate(data, 0))
|
||||||
.then(() => history.push("/groups"));
|
.then(() => history.push("/groups"))
|
||||||
|
: setErrorAlert(`Failed to delete group.`);
|
||||||
})
|
})
|
||||||
.catch((err) => console.log(err));
|
.catch(() => setErrorAlert(`Failed to delete group.`));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Delete Group
|
Delete Group
|
||||||
|
@@ -1,26 +1,30 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import Enzyme, { mount } from "enzyme";
|
import "@testing-library/jest-dom";
|
||||||
import GroupEdit from "./GroupEdit";
|
import { act } from "react-dom/test-utils";
|
||||||
import Adapter from "@wojtekmaj/enzyme-adapter-react-17";
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
import { Provider, useSelector } from "react-redux";
|
import { Provider, useSelector } from "react-redux";
|
||||||
import { createStore } from "redux";
|
import { createStore } from "redux";
|
||||||
import { HashRouter } from "react-router-dom";
|
import { HashRouter } from "react-router-dom";
|
||||||
import { act } from "react-dom/test-utils";
|
// eslint-disable-next-line
|
||||||
import regeneratorRuntime from "regenerator-runtime"; // eslint-disable-line
|
import regeneratorRuntime from "regenerator-runtime";
|
||||||
|
|
||||||
Enzyme.configure({ adapter: new Adapter() });
|
import GroupEdit from "./GroupEdit";
|
||||||
|
|
||||||
jest.mock("react-redux", () => ({
|
jest.mock("react-redux", () => ({
|
||||||
...jest.requireActual("react-redux"),
|
...jest.requireActual("react-redux"),
|
||||||
useSelector: jest.fn(),
|
useSelector: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("GroupEdit Component: ", () => {
|
var mockAsync = (data) =>
|
||||||
var mockAsync = () => jest.fn().mockImplementation(() => Promise.resolve());
|
jest.fn().mockImplementation(() => Promise.resolve(data));
|
||||||
|
|
||||||
var okPacket = new Promise((resolve) => resolve(true));
|
var mockAsyncRejection = () =>
|
||||||
|
jest.fn().mockImplementation(() => Promise.reject());
|
||||||
|
|
||||||
var groupEditJsx = (callbackSpy) => (
|
var okPacket = new Promise((resolve) => resolve(true));
|
||||||
|
|
||||||
|
var groupEditJsx = (callbackSpy) => (
|
||||||
<Provider store={createStore(() => {}, {})}>
|
<Provider store={createStore(() => {}, {})}>
|
||||||
<HashRouter>
|
<HashRouter>
|
||||||
<GroupEdit
|
<GroupEdit
|
||||||
@@ -39,62 +43,186 @@ describe("GroupEdit Component: ", () => {
|
|||||||
/>
|
/>
|
||||||
</HashRouter>
|
</HashRouter>
|
||||||
</Provider>
|
</Provider>
|
||||||
);
|
);
|
||||||
|
|
||||||
var mockAppState = () => ({
|
var mockAppState = () => ({
|
||||||
limit: 3,
|
limit: 3,
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
useSelector.mockImplementation((callback) => {
|
useSelector.mockImplementation((callback) => {
|
||||||
return callback(mockAppState());
|
return callback(mockAppState());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
useSelector.mockClear();
|
useSelector.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Adds user from input to user selectables on button click", async () => {
|
test("Renders", async () => {
|
||||||
let callbackSpy = mockAsync(),
|
let callbackSpy = mockAsync();
|
||||||
component = mount(groupEditJsx(callbackSpy)),
|
|
||||||
input = component.find("#username-input"),
|
await act(async () => {
|
||||||
validateUser = component.find("#validate-user"),
|
render(groupEditJsx(callbackSpy));
|
||||||
submit = component.find("#submit");
|
});
|
||||||
|
|
||||||
input.simulate("change", { target: { value: "bar" } });
|
expect(screen.getByTestId("container")).toBeVisible();
|
||||||
validateUser.simulate("click");
|
});
|
||||||
await act(() => okPacket);
|
|
||||||
submit.simulate("click");
|
test("Adds user from input to user selectables on button click", async () => {
|
||||||
expect(callbackSpy).toHaveBeenNthCalledWith(1, ["bar"], "group");
|
let callbackSpy = mockAsync();
|
||||||
});
|
|
||||||
|
await act(async () => {
|
||||||
it("Removes a user recently added from input from the selectables list", () => {
|
render(groupEditJsx(callbackSpy));
|
||||||
let callbackSpy = mockAsync(),
|
});
|
||||||
component = mount(groupEditJsx(callbackSpy)),
|
|
||||||
unsubmittedUser = component.find(".item.selected").last();
|
let input = screen.getByTestId("username-input");
|
||||||
unsubmittedUser.simulate("click");
|
let validateUser = screen.getByTestId("validate-user");
|
||||||
expect(component.find(".item").length).toBe(1);
|
let submit = screen.getByTestId("submit");
|
||||||
});
|
|
||||||
|
userEvent.type(input, "bar");
|
||||||
it("Grays out a user, already in the group, when unselected and calls deleteUser on submit", () => {
|
fireEvent.click(validateUser);
|
||||||
let callbackSpy = mockAsync(),
|
await act(async () => okPacket);
|
||||||
component = mount(groupEditJsx(callbackSpy)),
|
|
||||||
groupUser = component.find(".item.selected").first();
|
await act(async () => {
|
||||||
groupUser.simulate("click");
|
fireEvent.click(submit);
|
||||||
expect(component.find(".item.unselected").length).toBe(1);
|
});
|
||||||
expect(component.find(".item").length).toBe(1);
|
|
||||||
// test deleteUser call
|
expect(callbackSpy).toHaveBeenNthCalledWith(1, ["bar"], "group");
|
||||||
let submit = component.find("#submit");
|
});
|
||||||
submit.simulate("click");
|
|
||||||
expect(callbackSpy).toHaveBeenNthCalledWith(1, ["foo"], "group");
|
test("Removes a user recently added from input from the selectables list", async () => {
|
||||||
});
|
let callbackSpy = mockAsync();
|
||||||
|
|
||||||
it("Calls deleteGroup on button click", () => {
|
await act(async () => {
|
||||||
let callbackSpy = mockAsync(),
|
render(groupEditJsx(callbackSpy));
|
||||||
component = mount(groupEditJsx(callbackSpy)),
|
});
|
||||||
deleteGroup = component.find("#delete-group").first();
|
|
||||||
deleteGroup.simulate("click");
|
let selectedUser = screen.getByText("foo");
|
||||||
expect(callbackSpy).toHaveBeenNthCalledWith(1, "group");
|
fireEvent.click(selectedUser);
|
||||||
});
|
|
||||||
|
let unselectedUser = screen.getByText("foo");
|
||||||
|
|
||||||
|
expect(unselectedUser.className).toBe("item unselected");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Grays out a user, already in the group, when unselected and calls deleteUser on submit", async () => {
|
||||||
|
let callbackSpy = mockAsync();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
render(groupEditJsx(callbackSpy));
|
||||||
|
});
|
||||||
|
|
||||||
|
let submit = screen.getByTestId("submit");
|
||||||
|
|
||||||
|
let groupUser = screen.getByText("foo");
|
||||||
|
fireEvent.click(groupUser);
|
||||||
|
|
||||||
|
let unselectedUser = screen.getByText("foo");
|
||||||
|
expect(unselectedUser.className).toBe("item unselected");
|
||||||
|
|
||||||
|
// test deleteUser call
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(submit);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(callbackSpy).toHaveBeenNthCalledWith(1, ["foo"], "group");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Calls deleteGroup on button click", async () => {
|
||||||
|
let callbackSpy = mockAsync();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
render(groupEditJsx(callbackSpy));
|
||||||
|
});
|
||||||
|
|
||||||
|
let deleteGroup = screen.getByTestId("delete-group");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(deleteGroup);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(callbackSpy).toHaveBeenNthCalledWith(1, "group");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Shows a UI error dialogue when group edit fails", async () => {
|
||||||
|
let callbackSpy = mockAsyncRejection();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
render(groupEditJsx(callbackSpy));
|
||||||
|
});
|
||||||
|
|
||||||
|
let groupUser = screen.getByText("foo");
|
||||||
|
fireEvent.click(groupUser);
|
||||||
|
|
||||||
|
let submit = screen.getByTestId("submit");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(submit);
|
||||||
|
});
|
||||||
|
|
||||||
|
let errorDialog = screen.getByText("Failed to edit group.");
|
||||||
|
|
||||||
|
expect(errorDialog).toBeVisible();
|
||||||
|
expect(callbackSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Shows a UI error dialogue when group edit returns an improper status code", async () => {
|
||||||
|
let callbackSpy = mockAsync({ status: 403 });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
render(groupEditJsx(callbackSpy));
|
||||||
|
});
|
||||||
|
|
||||||
|
let groupUser = screen.getByText("foo");
|
||||||
|
fireEvent.click(groupUser);
|
||||||
|
|
||||||
|
let submit = screen.getByTestId("submit");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(submit);
|
||||||
|
});
|
||||||
|
|
||||||
|
let errorDialog = screen.getByText("Failed to edit group.");
|
||||||
|
|
||||||
|
expect(errorDialog).toBeVisible();
|
||||||
|
expect(callbackSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Shows a UI error dialogue when group delete fails", async () => {
|
||||||
|
let callbackSpy = mockAsyncRejection();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
render(groupEditJsx(callbackSpy));
|
||||||
|
});
|
||||||
|
|
||||||
|
let deleteGroup = screen.getByTestId("delete-group");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(deleteGroup);
|
||||||
|
});
|
||||||
|
|
||||||
|
let errorDialog = screen.getByText("Failed to delete group.");
|
||||||
|
|
||||||
|
expect(errorDialog).toBeVisible();
|
||||||
|
expect(callbackSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Shows a UI error dialogue when group delete returns an improper status code", async () => {
|
||||||
|
let callbackSpy = mockAsync({ status: 403 });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
render(groupEditJsx(callbackSpy));
|
||||||
|
});
|
||||||
|
|
||||||
|
let deleteGroup = screen.getByTestId("delete-group");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(deleteGroup);
|
||||||
|
});
|
||||||
|
|
||||||
|
let errorDialog = screen.getByText("Failed to delete group.");
|
||||||
|
|
||||||
|
expect(errorDialog).toBeVisible();
|
||||||
|
expect(callbackSpy).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
@@ -24,6 +24,7 @@ const GroupSelect = (props) => {
|
|||||||
<div className="input-group">
|
<div className="input-group">
|
||||||
<input
|
<input
|
||||||
id="username-input"
|
id="username-input"
|
||||||
|
data-testid="username-input"
|
||||||
type="text"
|
type="text"
|
||||||
className="form-control"
|
className="form-control"
|
||||||
placeholder="Add by username"
|
placeholder="Add by username"
|
||||||
@@ -35,6 +36,7 @@ const GroupSelect = (props) => {
|
|||||||
<span className="input-group-btn">
|
<span className="input-group-btn">
|
||||||
<button
|
<button
|
||||||
id="validate-user"
|
id="validate-user"
|
||||||
|
data-testid="validate-user"
|
||||||
className="btn btn-default"
|
className="btn btn-default"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
@@ -19,7 +19,7 @@ const Groups = (props) => {
|
|||||||
var { updateGroups, history } = props;
|
var { updateGroups, history } = props;
|
||||||
|
|
||||||
if (!groups_data || !user_data) {
|
if (!groups_data || !user_data) {
|
||||||
return <div></div>;
|
return <div data-testid="no-show"></div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dispatchPageChange = (data, page) => {
|
const dispatchPageChange = (data, page) => {
|
||||||
@@ -39,7 +39,7 @@ const Groups = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container">
|
<div className="container" data-testid="container">
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<div className="col-md-12 col-lg-10 col-lg-offset-1">
|
<div className="col-md-12 col-lg-10 col-lg-offset-1">
|
||||||
<div className="panel panel-default">
|
<div className="panel panel-default">
|
||||||
|
@@ -1,12 +1,14 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import Enzyme, { mount } from "enzyme";
|
import "@testing-library/jest-dom";
|
||||||
import Groups from "./Groups";
|
import { act } from "react-dom/test-utils";
|
||||||
import Adapter from "@wojtekmaj/enzyme-adapter-react-17";
|
import { render, screen } from "@testing-library/react";
|
||||||
import { Provider, useDispatch, useSelector } from "react-redux";
|
import { Provider, useDispatch, useSelector } from "react-redux";
|
||||||
import { createStore } from "redux";
|
import { createStore } from "redux";
|
||||||
import { HashRouter } from "react-router-dom";
|
import { HashRouter } from "react-router-dom";
|
||||||
|
// eslint-disable-next-line
|
||||||
|
import regeneratorRuntime from "regenerator-runtime";
|
||||||
|
|
||||||
Enzyme.configure({ adapter: new Adapter() });
|
import Groups from "./Groups";
|
||||||
|
|
||||||
jest.mock("react-redux", () => ({
|
jest.mock("react-redux", () => ({
|
||||||
...jest.requireActual("react-redux"),
|
...jest.requireActual("react-redux"),
|
||||||
@@ -14,52 +16,75 @@ jest.mock("react-redux", () => ({
|
|||||||
useDispatch: jest.fn(),
|
useDispatch: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("Groups Component: ", () => {
|
var mockAsync = () =>
|
||||||
var mockAsync = () =>
|
|
||||||
jest.fn().mockImplementation(() => Promise.resolve({ key: "value" }));
|
jest.fn().mockImplementation(() => Promise.resolve({ key: "value" }));
|
||||||
|
|
||||||
var groupsJsx = (callbackSpy) => (
|
var groupsJsx = (callbackSpy) => (
|
||||||
<Provider store={createStore(() => {}, {})}>
|
<Provider store={createStore(() => {}, {})}>
|
||||||
<HashRouter>
|
<HashRouter>
|
||||||
<Groups location={{ search: "0" }} updateGroups={callbackSpy} />
|
<Groups location={{ search: "0" }} updateGroups={callbackSpy} />
|
||||||
</HashRouter>
|
</HashRouter>
|
||||||
</Provider>
|
</Provider>
|
||||||
);
|
);
|
||||||
|
|
||||||
var mockAppState = () => ({
|
var mockAppState = () => ({
|
||||||
user_data: JSON.parse(
|
user_data: JSON.parse(
|
||||||
'[{"kind":"user","name":"foo","admin":true,"groups":[],"server":"/user/foo/","pending":null,"created":"2020-12-07T18:46:27.112695Z","last_activity":"2020-12-07T21:00:33.336354Z","servers":{"":{"name":"","last_activity":"2020-12-07T20:58:02.437408Z","started":"2020-12-07T20:58:01.508266Z","pending":null,"ready":true,"state":{"pid":28085},"url":"/user/foo/","user_options":{},"progress_url":"/hub/api/users/foo/server/progress"}}},{"kind":"user","name":"bar","admin":false,"groups":[],"server":null,"pending":null,"created":"2020-12-07T18:46:27.115528Z","last_activity":"2020-12-07T20:43:51.013613Z","servers":{}}]'
|
'[{"kind":"user","name":"foo","admin":true,"groups":[],"server":"/user/foo/","pending":null,"created":"2020-12-07T18:46:27.112695Z","last_activity":"2020-12-07T21:00:33.336354Z","servers":{"":{"name":"","last_activity":"2020-12-07T20:58:02.437408Z","started":"2020-12-07T20:58:01.508266Z","pending":null,"ready":true,"state":{"pid":28085},"url":"/user/foo/","user_options":{},"progress_url":"/hub/api/users/foo/server/progress"}}},{"kind":"user","name":"bar","admin":false,"groups":[],"server":null,"pending":null,"created":"2020-12-07T18:46:27.115528Z","last_activity":"2020-12-07T20:43:51.013613Z","servers":{}}]'
|
||||||
),
|
),
|
||||||
groups_data: JSON.parse(
|
groups_data: JSON.parse(
|
||||||
'[{"kind":"group","name":"testgroup","users":[]}, {"kind":"group","name":"testgroup2","users":["foo", "bar"]}]'
|
'[{"kind":"group","name":"testgroup","users":[]}, {"kind":"group","name":"testgroup2","users":["foo", "bar"]}]'
|
||||||
),
|
),
|
||||||
});
|
limit: 10,
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
useSelector.mockImplementation((callback) => {
|
useSelector.mockImplementation((callback) => {
|
||||||
return callback(mockAppState());
|
return callback(mockAppState());
|
||||||
});
|
});
|
||||||
useDispatch.mockImplementation(() => {
|
useDispatch.mockImplementation(() => {
|
||||||
return () => {};
|
return () => {};
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
useSelector.mockClear();
|
useSelector.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Renders", async () => {
|
||||||
|
let callbackSpy = mockAsync();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
render(groupsJsx(callbackSpy));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Renders groups_data prop into links", () => {
|
expect(screen.getByTestId("container")).toBeVisible();
|
||||||
let callbackSpy = mockAsync(),
|
});
|
||||||
component = mount(groupsJsx(callbackSpy)),
|
|
||||||
links = component.find("li");
|
test("Renders groups_data prop into links", async () => {
|
||||||
expect(links.length).toBe(2);
|
let callbackSpy = mockAsync();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
render(groupsJsx(callbackSpy));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Renders nothing if required data is not available", () => {
|
let testgroup = screen.getByText("testgroup");
|
||||||
|
let testgroup2 = screen.getByText("testgroup2");
|
||||||
|
|
||||||
|
expect(testgroup).toBeVisible();
|
||||||
|
expect(testgroup2).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Renders nothing if required data is not available", async () => {
|
||||||
useSelector.mockImplementation((callback) => {
|
useSelector.mockImplementation((callback) => {
|
||||||
return callback({});
|
return callback({});
|
||||||
});
|
});
|
||||||
let component = mount(groupsJsx());
|
|
||||||
expect(component.html()).toBe("<div></div>");
|
let callbackSpy = mockAsync();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
render(groupsJsx(callbackSpy));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let noShow = screen.getByTestId("no-show");
|
||||||
|
expect(noShow).toBeVisible();
|
||||||
});
|
});
|
||||||
|
@@ -1,8 +1,19 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
|
import regeneratorRuntime from "regenerator-runtime";
|
||||||
import { useSelector, useDispatch } from "react-redux";
|
import { useSelector, useDispatch } from "react-redux";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
|
|
||||||
import { Button } from "react-bootstrap";
|
import {
|
||||||
|
Button,
|
||||||
|
Col,
|
||||||
|
Row,
|
||||||
|
FormControl,
|
||||||
|
Card,
|
||||||
|
CardGroup,
|
||||||
|
Collapse,
|
||||||
|
} from "react-bootstrap";
|
||||||
|
import ReactObjectTableViewer from "react-object-table-viewer";
|
||||||
|
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { FaSort, FaSortUp, FaSortDown } from "react-icons/fa";
|
import { FaSort, FaSortUp, FaSortDown } from "react-icons/fa";
|
||||||
|
|
||||||
@@ -10,7 +21,16 @@ import "./server-dashboard.css";
|
|||||||
import { timeSince } from "../../util/timeSince";
|
import { timeSince } from "../../util/timeSince";
|
||||||
import PaginationFooter from "../PaginationFooter/PaginationFooter";
|
import PaginationFooter from "../PaginationFooter/PaginationFooter";
|
||||||
|
|
||||||
|
const AccessServerButton = ({ url }) => (
|
||||||
|
<a href={url || ""}>
|
||||||
|
<button className="btn btn-primary btn-xs" style={{ marginRight: 20 }}>
|
||||||
|
Access Server
|
||||||
|
</button>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
|
||||||
const ServerDashboard = (props) => {
|
const ServerDashboard = (props) => {
|
||||||
|
let base_url = window.base_url || "/";
|
||||||
// sort methods
|
// sort methods
|
||||||
var usernameDesc = (e) => e.sort((a, b) => (a.name > b.name ? 1 : -1)),
|
var usernameDesc = (e) => e.sort((a, b) => (a.name > b.name ? 1 : -1)),
|
||||||
usernameAsc = (e) => e.sort((a, b) => (a.name < b.name ? 1 : -1)),
|
usernameAsc = (e) => e.sort((a, b) => (a.name < b.name ? 1 : -1)),
|
||||||
@@ -27,15 +47,19 @@ const ServerDashboard = (props) => {
|
|||||||
runningAsc = (e) => e.sort((a) => (a.server == null ? -1 : 1)),
|
runningAsc = (e) => e.sort((a) => (a.server == null ? -1 : 1)),
|
||||||
runningDesc = (e) => e.sort((a) => (a.server == null ? 1 : -1));
|
runningDesc = (e) => e.sort((a) => (a.server == null ? 1 : -1));
|
||||||
|
|
||||||
|
var [errorAlert, setErrorAlert] = useState(null);
|
||||||
var [sortMethod, setSortMethod] = useState(null);
|
var [sortMethod, setSortMethod] = useState(null);
|
||||||
|
var [disabledButtons, setDisabledButtons] = useState({});
|
||||||
|
const [collapseStates, setCollapseStates] = useState({});
|
||||||
|
|
||||||
var user_data = useSelector((state) => state.user_data),
|
var user_data = useSelector((state) => state.user_data),
|
||||||
user_page = useSelector((state) => state.user_page),
|
user_page = useSelector((state) => state.user_page),
|
||||||
limit = useSelector((state) => state.limit),
|
limit = useSelector((state) => state.limit),
|
||||||
|
name_filter = useSelector((state) => state.name_filter),
|
||||||
page = parseInt(new URLSearchParams(props.location.search).get("page"));
|
page = parseInt(new URLSearchParams(props.location.search).get("page"));
|
||||||
|
|
||||||
page = isNaN(page) ? 0 : page;
|
page = isNaN(page) ? 0 : page;
|
||||||
var slice = [page * limit, limit];
|
var slice = [page * limit, limit, name_filter];
|
||||||
|
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
@@ -49,35 +73,312 @@ const ServerDashboard = (props) => {
|
|||||||
history,
|
history,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
var dispatchPageUpdate = (data, page) => {
|
var dispatchPageUpdate = (data, page, name_filter) => {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "USER_PAGE",
|
type: "USER_PAGE",
|
||||||
value: {
|
value: {
|
||||||
data: data,
|
data: data,
|
||||||
page: page,
|
page: page,
|
||||||
|
name_filter: name_filter,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!user_data) {
|
if (!user_data) {
|
||||||
return <div></div>;
|
return <div data-testid="no-show"></div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (page != user_page) {
|
if (page != user_page) {
|
||||||
updateUsers(...slice).then((data) => dispatchPageUpdate(data, page));
|
updateUsers(...slice).then((data) =>
|
||||||
|
dispatchPageUpdate(data, page, name_filter)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var debounce = require("lodash.debounce");
|
||||||
|
const handleSearch = debounce(async (event) => {
|
||||||
|
// setNameFilter(event.target.value);
|
||||||
|
updateUsers(page * limit, limit, event.target.value).then((data) =>
|
||||||
|
dispatchPageUpdate(data, page, name_filter)
|
||||||
|
);
|
||||||
|
}, 300);
|
||||||
|
|
||||||
if (sortMethod != null) {
|
if (sortMethod != null) {
|
||||||
user_data = sortMethod(user_data);
|
user_data = sortMethod(user_data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const StopServerButton = ({ serverName, userName }) => {
|
||||||
|
var [isDisabled, setIsDisabled] = useState(false);
|
||||||
return (
|
return (
|
||||||
<div className="container">
|
<button
|
||||||
<div className="manage-groups" style={{ float: "right", margin: "20px" }}>
|
className="btn btn-danger btn-xs stop-button"
|
||||||
<Link to="/groups">{"> Manage Groups"}</Link>
|
disabled={isDisabled}
|
||||||
|
onClick={() => {
|
||||||
|
setIsDisabled(true);
|
||||||
|
stopServer(userName, serverName)
|
||||||
|
.then((res) => {
|
||||||
|
if (res.status < 300) {
|
||||||
|
updateUsers(...slice)
|
||||||
|
.then((data) => {
|
||||||
|
dispatchPageUpdate(data, page, name_filter);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setIsDisabled(false);
|
||||||
|
setErrorAlert(`Failed to update users list.`);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setErrorAlert(`Failed to stop server.`);
|
||||||
|
setIsDisabled(false);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setErrorAlert(`Failed to stop server.`);
|
||||||
|
setIsDisabled(false);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Stop Server
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const StartServerButton = ({ serverName, userName }) => {
|
||||||
|
var [isDisabled, setIsDisabled] = useState(false);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className="btn btn-success btn-xs start-button"
|
||||||
|
disabled={isDisabled}
|
||||||
|
onClick={() => {
|
||||||
|
setIsDisabled(true);
|
||||||
|
startServer(userName, serverName)
|
||||||
|
.then((res) => {
|
||||||
|
if (res.status < 300) {
|
||||||
|
updateUsers(...slice)
|
||||||
|
.then((data) => {
|
||||||
|
dispatchPageUpdate(data, page, name_filter);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setErrorAlert(`Failed to update users list.`);
|
||||||
|
setIsDisabled(false);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setErrorAlert(`Failed to start server.`);
|
||||||
|
setIsDisabled(false);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setErrorAlert(`Failed to start server.`);
|
||||||
|
setIsDisabled(false);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Start Server
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const EditUserCell = ({ user }) => {
|
||||||
|
return (
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary btn-xs"
|
||||||
|
style={{ marginRight: 20 }}
|
||||||
|
onClick={() =>
|
||||||
|
history.push({
|
||||||
|
pathname: "/edit-user",
|
||||||
|
state: {
|
||||||
|
username: user.name,
|
||||||
|
has_admin: user.admin,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Edit User
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ServerRowTable = ({ data }) => {
|
||||||
|
const sortedData = Object.keys(data)
|
||||||
|
.sort()
|
||||||
|
.reduce(function (result, key) {
|
||||||
|
let value = data[key];
|
||||||
|
switch (key) {
|
||||||
|
case "last_activity":
|
||||||
|
case "created":
|
||||||
|
case "started":
|
||||||
|
// format timestamps
|
||||||
|
value = value ? timeSince(value) : value;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
// cast arrays (e.g. roles, groups) to string
|
||||||
|
value = value.sort().join(", ");
|
||||||
|
}
|
||||||
|
result[key] = value;
|
||||||
|
return result;
|
||||||
|
}, {});
|
||||||
|
return (
|
||||||
|
<ReactObjectTableViewer
|
||||||
|
className="table-striped table-bordered"
|
||||||
|
style={{
|
||||||
|
padding: "3px 6px",
|
||||||
|
margin: "auto",
|
||||||
|
}}
|
||||||
|
keyStyle={{
|
||||||
|
padding: "4px",
|
||||||
|
}}
|
||||||
|
valueStyle={{
|
||||||
|
padding: "4px",
|
||||||
|
}}
|
||||||
|
data={sortedData}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const serverRow = (user, server) => {
|
||||||
|
const { servers, ...userNoServers } = user;
|
||||||
|
const serverNameDash = server.name ? `-${server.name}` : "";
|
||||||
|
const userServerName = user.name + serverNameDash;
|
||||||
|
const open = collapseStates[userServerName] || false;
|
||||||
|
return [
|
||||||
|
<tr key={`${userServerName}-row`} className="user-row">
|
||||||
|
<td data-testid="user-row-name">
|
||||||
|
<span>
|
||||||
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
setCollapseStates({
|
||||||
|
...collapseStates,
|
||||||
|
[userServerName]: !open,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
aria-controls={`${userServerName}-collapse`}
|
||||||
|
aria-expanded={open}
|
||||||
|
data-testid={`${userServerName}-collapse-button`}
|
||||||
|
variant={open ? "secondary" : "primary"}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<span className="caret"></span>
|
||||||
|
</Button>{" "}
|
||||||
|
</span>
|
||||||
|
<span data-testid={`user-name-div-${userServerName}`}>
|
||||||
|
{user.name}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td data-testid="user-row-admin">{user.admin ? "admin" : ""}</td>
|
||||||
|
|
||||||
|
<td data-testid="user-row-server">
|
||||||
|
<p className="text-secondary">{server.name}</p>
|
||||||
|
</td>
|
||||||
|
<td data-testid="user-row-last-activity">
|
||||||
|
{server.last_activity ? timeSince(server.last_activity) : "Never"}
|
||||||
|
</td>
|
||||||
|
<td data-testid="user-row-server-activity">
|
||||||
|
{server.started ? (
|
||||||
|
// Stop Single-user server
|
||||||
|
<>
|
||||||
|
<StopServerButton serverName={server.name} userName={user.name} />
|
||||||
|
<AccessServerButton url={server.url} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
// Start Single-user server
|
||||||
|
<>
|
||||||
|
<StartServerButton
|
||||||
|
serverName={server.name}
|
||||||
|
userName={user.name}
|
||||||
|
style={{ marginRight: 20 }}
|
||||||
|
/>
|
||||||
|
<a
|
||||||
|
href={`${base_url}spawn/${user.name}${
|
||||||
|
server.name ? "/" + server.name : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary btn-xs"
|
||||||
|
style={{ marginRight: 20 }}
|
||||||
|
>
|
||||||
|
Spawn Page
|
||||||
|
</button>
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<EditUserCell user={user} />
|
||||||
|
</tr>,
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={6}
|
||||||
|
style={{ padding: 0 }}
|
||||||
|
data-testid={`${userServerName}-td`}
|
||||||
|
>
|
||||||
|
<Collapse in={open} data-testid={`${userServerName}-collapse`}>
|
||||||
|
<CardGroup
|
||||||
|
id={`${userServerName}-card-group`}
|
||||||
|
style={{ width: "100%", margin: "0 auto", float: "none" }}
|
||||||
|
>
|
||||||
|
<Card style={{ width: "100%", padding: 3, margin: "0 auto" }}>
|
||||||
|
<Card.Title>User</Card.Title>
|
||||||
|
<ServerRowTable data={userNoServers} />
|
||||||
|
</Card>
|
||||||
|
<Card style={{ width: "100%", padding: 3, margin: "0 auto" }}>
|
||||||
|
<Card.Title>Server</Card.Title>
|
||||||
|
<ServerRowTable data={server} />
|
||||||
|
</Card>
|
||||||
|
</CardGroup>
|
||||||
|
</Collapse>
|
||||||
|
</td>
|
||||||
|
</tr>,
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
let servers = user_data.flatMap((user) => {
|
||||||
|
let userServers = Object.values({
|
||||||
|
"": user.server || {},
|
||||||
|
...(user.servers || {}),
|
||||||
|
});
|
||||||
|
return userServers.map((server) => [user, server]);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container" data-testid="container">
|
||||||
|
{errorAlert != null ? (
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-md-10 col-md-offset-1 col-lg-8 col-lg-offset-2">
|
||||||
|
<div className="alert alert-danger">
|
||||||
|
{errorAlert}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="close"
|
||||||
|
onClick={() => setErrorAlert(null)}
|
||||||
|
>
|
||||||
|
<span>×</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<></>
|
||||||
|
)}
|
||||||
<div className="server-dashboard-container">
|
<div className="server-dashboard-container">
|
||||||
<table className="table table-striped table-bordered table-hover">
|
<Row>
|
||||||
|
<Col md={4}>
|
||||||
|
<FormControl
|
||||||
|
type="text"
|
||||||
|
name="user_search"
|
||||||
|
placeholder="Search users"
|
||||||
|
aria-label="user-search"
|
||||||
|
defaultValue={name_filter}
|
||||||
|
onChange={handleSearch}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col md="auto" style={{ float: "right", margin: 15 }}>
|
||||||
|
<Link to="/groups">{"> Manage Groups"}</Link>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<table className="table table-bordered table-hover">
|
||||||
<thead className="admin-table-head">
|
<thead className="admin-table-head">
|
||||||
<tr>
|
<tr>
|
||||||
<th id="user-header">
|
<th id="user-header">
|
||||||
@@ -85,6 +386,7 @@ const ServerDashboard = (props) => {
|
|||||||
<SortHandler
|
<SortHandler
|
||||||
sorts={{ asc: usernameAsc, desc: usernameDesc }}
|
sorts={{ asc: usernameAsc, desc: usernameDesc }}
|
||||||
callback={(method) => setSortMethod(() => method)}
|
callback={(method) => setSortMethod(() => method)}
|
||||||
|
testid="user-sort"
|
||||||
/>
|
/>
|
||||||
</th>
|
</th>
|
||||||
<th id="admin-header">
|
<th id="admin-header">
|
||||||
@@ -92,6 +394,15 @@ const ServerDashboard = (props) => {
|
|||||||
<SortHandler
|
<SortHandler
|
||||||
sorts={{ asc: adminAsc, desc: adminDesc }}
|
sorts={{ asc: adminAsc, desc: adminDesc }}
|
||||||
callback={(method) => setSortMethod(() => method)}
|
callback={(method) => setSortMethod(() => method)}
|
||||||
|
testid="admin-sort"
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th id="server-header">
|
||||||
|
Server{" "}
|
||||||
|
<SortHandler
|
||||||
|
sorts={{ asc: usernameAsc, desc: usernameDesc }}
|
||||||
|
callback={(method) => setSortMethod(() => method)}
|
||||||
|
testid="server-sort"
|
||||||
/>
|
/>
|
||||||
</th>
|
</th>
|
||||||
<th id="last-activity-header">
|
<th id="last-activity-header">
|
||||||
@@ -99,6 +410,7 @@ const ServerDashboard = (props) => {
|
|||||||
<SortHandler
|
<SortHandler
|
||||||
sorts={{ asc: dateAsc, desc: dateDesc }}
|
sorts={{ asc: dateAsc, desc: dateDesc }}
|
||||||
callback={(method) => setSortMethod(() => method)}
|
callback={(method) => setSortMethod(() => method)}
|
||||||
|
testid="last-activity-sort"
|
||||||
/>
|
/>
|
||||||
</th>
|
</th>
|
||||||
<th id="running-status-header">
|
<th id="running-status-header">
|
||||||
@@ -106,6 +418,7 @@ const ServerDashboard = (props) => {
|
|||||||
<SortHandler
|
<SortHandler
|
||||||
sorts={{ asc: runningAsc, desc: runningDesc }}
|
sorts={{ asc: runningAsc, desc: runningDesc }}
|
||||||
callback={(method) => setSortMethod(() => method)}
|
callback={(method) => setSortMethod(() => method)}
|
||||||
|
testid="running-status-sort"
|
||||||
/>
|
/>
|
||||||
</th>
|
</th>
|
||||||
<th id="actions-header">Actions</th>
|
<th id="actions-header">Actions</th>
|
||||||
@@ -125,17 +438,33 @@ const ServerDashboard = (props) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
className="start-all"
|
className="start-all"
|
||||||
|
data-testid="start-all"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
Promise.all(startAll(user_data.map((e) => e.name)))
|
Promise.all(startAll(user_data.map((e) => e.name)))
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
updateUsers(...slice)
|
let failedServers = res.filter((e) => !e.ok);
|
||||||
.then((data) => {
|
if (failedServers.length > 0) {
|
||||||
dispatchPageUpdate(data, page);
|
setErrorAlert(
|
||||||
})
|
`Failed to start ${failedServers.length} ${
|
||||||
.catch((err) => console.log(err));
|
failedServers.length > 1 ? "servers" : "server"
|
||||||
|
}. ${
|
||||||
|
failedServers.length > 1 ? "Are they " : "Is it "
|
||||||
|
} already running?`
|
||||||
|
);
|
||||||
|
}
|
||||||
return res;
|
return res;
|
||||||
})
|
})
|
||||||
.catch((err) => console.log(err));
|
.then((res) => {
|
||||||
|
updateUsers(...slice)
|
||||||
|
.then((data) => {
|
||||||
|
dispatchPageUpdate(data, page, name_filter);
|
||||||
|
})
|
||||||
|
.catch(() =>
|
||||||
|
setErrorAlert(`Failed to update users list.`)
|
||||||
|
);
|
||||||
|
return res;
|
||||||
|
})
|
||||||
|
.catch(() => setErrorAlert(`Failed to start servers.`));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Start All
|
Start All
|
||||||
@@ -145,17 +474,33 @@ const ServerDashboard = (props) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="danger"
|
variant="danger"
|
||||||
className="stop-all"
|
className="stop-all"
|
||||||
|
data-testid="stop-all"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
Promise.all(stopAll(user_data.map((e) => e.name)))
|
Promise.all(stopAll(user_data.map((e) => e.name)))
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
updateUsers(...slice)
|
let failedServers = res.filter((e) => !e.ok);
|
||||||
.then((data) => {
|
if (failedServers.length > 0) {
|
||||||
dispatchPageUpdate(data, page);
|
setErrorAlert(
|
||||||
})
|
`Failed to stop ${failedServers.length} ${
|
||||||
.catch((err) => console.log(err));
|
failedServers.length > 1 ? "servers" : "server"
|
||||||
|
}. ${
|
||||||
|
failedServers.length > 1 ? "Are they " : "Is it "
|
||||||
|
} already stopped?`
|
||||||
|
);
|
||||||
|
}
|
||||||
return res;
|
return res;
|
||||||
})
|
})
|
||||||
.catch((err) => console.log(err));
|
.then((res) => {
|
||||||
|
updateUsers(...slice)
|
||||||
|
.then((data) => {
|
||||||
|
dispatchPageUpdate(data, page, name_filter);
|
||||||
|
})
|
||||||
|
.catch(() =>
|
||||||
|
setErrorAlert(`Failed to update users list.`)
|
||||||
|
);
|
||||||
|
return res;
|
||||||
|
})
|
||||||
|
.catch(() => setErrorAlert(`Failed to stop servers.`));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Stop All
|
Stop All
|
||||||
@@ -172,70 +517,7 @@ const ServerDashboard = (props) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{user_data.map((e, i) => (
|
{servers.flatMap(([user, server]) => serverRow(user, server))}
|
||||||
<tr key={i + "row"} className="user-row">
|
|
||||||
<td>{e.name}</td>
|
|
||||||
<td>{e.admin ? "admin" : ""}</td>
|
|
||||||
<td>
|
|
||||||
{e.last_activity ? timeSince(e.last_activity) : "Never"}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{e.server != null ? (
|
|
||||||
// Stop Single-user server
|
|
||||||
<button
|
|
||||||
className="btn btn-danger btn-xs stop-button"
|
|
||||||
onClick={() =>
|
|
||||||
stopServer(e.name)
|
|
||||||
.then((res) => {
|
|
||||||
updateUsers(...slice).then((data) => {
|
|
||||||
dispatchPageUpdate(data, page);
|
|
||||||
});
|
|
||||||
return res;
|
|
||||||
})
|
|
||||||
.catch((err) => console.log(err))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Stop Server
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
// Start Single-user server
|
|
||||||
<button
|
|
||||||
className="btn btn-primary btn-xs start-button"
|
|
||||||
onClick={() =>
|
|
||||||
startServer(e.name)
|
|
||||||
.then((res) => {
|
|
||||||
updateUsers(...slice).then((data) => {
|
|
||||||
dispatchPageUpdate(data, page);
|
|
||||||
});
|
|
||||||
return res;
|
|
||||||
})
|
|
||||||
.catch((err) => console.log(err))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Start Server
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{/* Edit User */}
|
|
||||||
<button
|
|
||||||
className="btn btn-primary btn-xs"
|
|
||||||
style={{ marginRight: 20 }}
|
|
||||||
onClick={() =>
|
|
||||||
history.push({
|
|
||||||
pathname: "/edit-user",
|
|
||||||
state: {
|
|
||||||
username: e.name,
|
|
||||||
has_admin: e.admin,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
edit user
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<PaginationFooter
|
<PaginationFooter
|
||||||
@@ -269,13 +551,14 @@ ServerDashboard.propTypes = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const SortHandler = (props) => {
|
const SortHandler = (props) => {
|
||||||
var { sorts, callback } = props;
|
var { sorts, callback, testid } = props;
|
||||||
|
|
||||||
var [direction, setDirection] = useState(undefined);
|
var [direction, setDirection] = useState(undefined);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="sort-icon"
|
className="sort-icon"
|
||||||
|
data-testid={testid}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!direction) {
|
if (!direction) {
|
||||||
callback(sorts.desc);
|
callback(sorts.desc);
|
||||||
@@ -303,6 +586,7 @@ const SortHandler = (props) => {
|
|||||||
SortHandler.propTypes = {
|
SortHandler.propTypes = {
|
||||||
sorts: PropTypes.object,
|
sorts: PropTypes.object,
|
||||||
callback: PropTypes.func,
|
callback: PropTypes.func,
|
||||||
|
testid: PropTypes.string,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ServerDashboard;
|
export default ServerDashboard;
|
||||||
|
@@ -1,161 +1,535 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import Enzyme, { mount } from "enzyme";
|
import "@testing-library/jest-dom";
|
||||||
import ServerDashboard from "./ServerDashboard";
|
import { act } from "react-dom/test-utils";
|
||||||
import Adapter from "@wojtekmaj/enzyme-adapter-react-17";
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
import { HashRouter, Switch } from "react-router-dom";
|
import { HashRouter, Switch } from "react-router-dom";
|
||||||
import { Provider, useSelector } from "react-redux";
|
import { Provider, useSelector } from "react-redux";
|
||||||
import { createStore } from "redux";
|
import { createStore } from "redux";
|
||||||
|
// eslint-disable-next-line
|
||||||
|
import regeneratorRuntime from "regenerator-runtime";
|
||||||
|
|
||||||
Enzyme.configure({ adapter: new Adapter() });
|
import ServerDashboard from "./ServerDashboard";
|
||||||
|
import * as sinon from "sinon";
|
||||||
|
|
||||||
|
let clock;
|
||||||
|
|
||||||
jest.mock("react-redux", () => ({
|
jest.mock("react-redux", () => ({
|
||||||
...jest.requireActual("react-redux"),
|
...jest.requireActual("react-redux"),
|
||||||
useSelector: jest.fn(),
|
useSelector: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("ServerDashboard Component: ", () => {
|
var serverDashboardJsx = (spy) => (
|
||||||
var serverDashboardJsx = (callbackSpy) => (
|
|
||||||
<Provider store={createStore(() => {}, {})}>
|
<Provider store={createStore(() => {}, {})}>
|
||||||
<HashRouter>
|
<HashRouter>
|
||||||
<Switch>
|
<Switch>
|
||||||
<ServerDashboard
|
<ServerDashboard
|
||||||
updateUsers={callbackSpy}
|
updateUsers={spy}
|
||||||
shutdownHub={callbackSpy}
|
shutdownHub={spy}
|
||||||
startServer={callbackSpy}
|
startServer={spy}
|
||||||
stopServer={callbackSpy}
|
stopServer={spy}
|
||||||
startAll={callbackSpy}
|
startAll={spy}
|
||||||
stopAll={callbackSpy}
|
stopAll={spy}
|
||||||
|
/>
|
||||||
|
</Switch>
|
||||||
|
</HashRouter>
|
||||||
|
</Provider>
|
||||||
|
);
|
||||||
|
|
||||||
|
var mockAsync = (data) =>
|
||||||
|
jest.fn().mockImplementation(() => Promise.resolve(data ? data : { k: "v" }));
|
||||||
|
|
||||||
|
var mockAsyncRejection = () =>
|
||||||
|
jest.fn().mockImplementation(() => Promise.reject());
|
||||||
|
|
||||||
|
var mockAppState = () => ({
|
||||||
|
user_data: JSON.parse(
|
||||||
|
'[{"kind":"user","name":"foo","admin":true,"groups":[],"server":"/user/foo/","pending":null,"created":"2020-12-07T18:46:27.112695Z","last_activity":"2020-12-07T21:00:33.336354Z","servers":{"":{"name":"","last_activity":"2020-12-07T20:58:02.437408Z","started":"2020-12-07T20:58:01.508266Z","pending":null,"ready":true,"state":{"pid":28085},"url":"/user/foo/","user_options":{},"progress_url":"/hub/api/users/foo/server/progress"}}},{"kind":"user","name":"bar","admin":false,"groups":[],"server":null,"pending":null,"created":"2020-12-07T18:46:27.115528Z","last_activity":"2020-12-07T20:43:51.013613Z","servers":{}}]'
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
clock = sinon.useFakeTimers();
|
||||||
|
useSelector.mockImplementation((callback) => {
|
||||||
|
return callback(mockAppState());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
useSelector.mockClear();
|
||||||
|
clock.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Renders", async () => {
|
||||||
|
let callbackSpy = mockAsync();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
render(serverDashboardJsx(callbackSpy));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByTestId("container")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Renders users from props.user_data into table", async () => {
|
||||||
|
let callbackSpy = mockAsync();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
render(serverDashboardJsx(callbackSpy));
|
||||||
|
});
|
||||||
|
|
||||||
|
let foo = screen.getByTestId("user-name-div-foo");
|
||||||
|
let bar = screen.getByTestId("user-name-div-bar");
|
||||||
|
|
||||||
|
expect(foo).toBeVisible();
|
||||||
|
expect(bar).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Renders correctly the status of a single-user server", async () => {
|
||||||
|
let callbackSpy = mockAsync();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
render(serverDashboardJsx(callbackSpy));
|
||||||
|
});
|
||||||
|
|
||||||
|
let start = screen.getByText("Start Server");
|
||||||
|
let stop = screen.getByText("Stop Server");
|
||||||
|
|
||||||
|
expect(start).toBeVisible();
|
||||||
|
expect(stop).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Renders spawn page link", async () => {
|
||||||
|
let callbackSpy = mockAsync();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
render(serverDashboardJsx(callbackSpy));
|
||||||
|
});
|
||||||
|
|
||||||
|
let link = screen.getByText("Spawn Page").closest("a");
|
||||||
|
let url = new URL(link.href);
|
||||||
|
expect(url.pathname).toEqual("/spawn/bar");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Invokes the startServer event on button click", async () => {
|
||||||
|
let callbackSpy = mockAsync();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
render(serverDashboardJsx(callbackSpy));
|
||||||
|
});
|
||||||
|
|
||||||
|
let start = screen.getByText("Start Server");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(start);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(callbackSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Invokes the stopServer event on button click", async () => {
|
||||||
|
let callbackSpy = mockAsync();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
render(serverDashboardJsx(callbackSpy));
|
||||||
|
});
|
||||||
|
|
||||||
|
let stop = screen.getByText("Stop Server");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(stop);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(callbackSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Invokes the shutdownHub event on button click", async () => {
|
||||||
|
let callbackSpy = mockAsync();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
render(serverDashboardJsx(callbackSpy));
|
||||||
|
});
|
||||||
|
|
||||||
|
let shutdown = screen.getByText("Shutdown Hub");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(shutdown);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(callbackSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Sorts according to username", async () => {
|
||||||
|
let callbackSpy = mockAsync();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
render(serverDashboardJsx(callbackSpy));
|
||||||
|
});
|
||||||
|
|
||||||
|
let handler = screen.getByTestId("user-sort");
|
||||||
|
fireEvent.click(handler);
|
||||||
|
|
||||||
|
let first = screen.getAllByTestId("user-row-name")[0];
|
||||||
|
expect(first.textContent).toContain("bar");
|
||||||
|
|
||||||
|
fireEvent.click(handler);
|
||||||
|
|
||||||
|
first = screen.getAllByTestId("user-row-name")[0];
|
||||||
|
expect(first.textContent).toContain("foo");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Sorts according to admin", async () => {
|
||||||
|
let callbackSpy = mockAsync();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
render(serverDashboardJsx(callbackSpy));
|
||||||
|
});
|
||||||
|
|
||||||
|
let handler = screen.getByTestId("admin-sort");
|
||||||
|
fireEvent.click(handler);
|
||||||
|
|
||||||
|
let first = screen.getAllByTestId("user-row-admin")[0];
|
||||||
|
expect(first.textContent).toBe("admin");
|
||||||
|
|
||||||
|
fireEvent.click(handler);
|
||||||
|
|
||||||
|
first = screen.getAllByTestId("user-row-admin")[0];
|
||||||
|
expect(first.textContent).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Sorts according to last activity", async () => {
|
||||||
|
let callbackSpy = mockAsync();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
render(serverDashboardJsx(callbackSpy));
|
||||||
|
});
|
||||||
|
|
||||||
|
let handler = screen.getByTestId("last-activity-sort");
|
||||||
|
fireEvent.click(handler);
|
||||||
|
|
||||||
|
let first = screen.getAllByTestId("user-row-name")[0];
|
||||||
|
expect(first.textContent).toContain("foo");
|
||||||
|
|
||||||
|
fireEvent.click(handler);
|
||||||
|
|
||||||
|
first = screen.getAllByTestId("user-row-name")[0];
|
||||||
|
expect(first.textContent).toContain("bar");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Sorts according to server status (running/not running)", async () => {
|
||||||
|
let callbackSpy = mockAsync();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
render(serverDashboardJsx(callbackSpy));
|
||||||
|
});
|
||||||
|
|
||||||
|
let handler = screen.getByTestId("running-status-sort");
|
||||||
|
fireEvent.click(handler);
|
||||||
|
|
||||||
|
let first = screen.getAllByTestId("user-row-name")[0];
|
||||||
|
expect(first.textContent).toContain("foo");
|
||||||
|
|
||||||
|
fireEvent.click(handler);
|
||||||
|
|
||||||
|
first = screen.getAllByTestId("user-row-name")[0];
|
||||||
|
expect(first.textContent).toContain("bar");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Shows server details with button click", async () => {
|
||||||
|
let callbackSpy = mockAsync();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
render(serverDashboardJsx(callbackSpy));
|
||||||
|
});
|
||||||
|
let button = screen.getByTestId("foo-collapse-button");
|
||||||
|
let collapse = screen.getByTestId("foo-collapse");
|
||||||
|
let collapseBar = screen.getByTestId("bar-collapse");
|
||||||
|
|
||||||
|
// expect().toBeVisible does not work here with collapse.
|
||||||
|
expect(collapse).toHaveClass("collapse");
|
||||||
|
expect(collapse).not.toHaveClass("show");
|
||||||
|
expect(collapseBar).not.toHaveClass("show");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(button);
|
||||||
|
});
|
||||||
|
clock.tick(400);
|
||||||
|
|
||||||
|
expect(collapse).toHaveClass("collapse show");
|
||||||
|
expect(collapseBar).not.toHaveClass("show");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(button);
|
||||||
|
});
|
||||||
|
clock.tick(400);
|
||||||
|
|
||||||
|
expect(collapse).toHaveClass("collapse");
|
||||||
|
expect(collapse).not.toHaveClass("show");
|
||||||
|
expect(collapseBar).not.toHaveClass("show");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(button);
|
||||||
|
});
|
||||||
|
clock.tick(400);
|
||||||
|
|
||||||
|
expect(collapse).toHaveClass("collapse show");
|
||||||
|
expect(collapseBar).not.toHaveClass("show");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Renders nothing if required data is not available", async () => {
|
||||||
|
useSelector.mockImplementation((callback) => {
|
||||||
|
return callback({});
|
||||||
|
});
|
||||||
|
|
||||||
|
let callbackSpy = mockAsync();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
render(serverDashboardJsx(callbackSpy));
|
||||||
|
});
|
||||||
|
|
||||||
|
let noShow = screen.getByTestId("no-show");
|
||||||
|
|
||||||
|
expect(noShow).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Shows a UI error dialogue when start all servers fails", async () => {
|
||||||
|
let spy = mockAsync();
|
||||||
|
let rejectSpy = mockAsyncRejection;
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
render(
|
||||||
|
<Provider store={createStore(() => {}, {})}>
|
||||||
|
<HashRouter>
|
||||||
|
<Switch>
|
||||||
|
<ServerDashboard
|
||||||
|
updateUsers={spy}
|
||||||
|
shutdownHub={spy}
|
||||||
|
startServer={spy}
|
||||||
|
stopServer={spy}
|
||||||
|
startAll={rejectSpy}
|
||||||
|
stopAll={spy}
|
||||||
/>
|
/>
|
||||||
</Switch>
|
</Switch>
|
||||||
</HashRouter>
|
</HashRouter>
|
||||||
</Provider>
|
</Provider>
|
||||||
);
|
);
|
||||||
|
|
||||||
var mockAsync = () =>
|
|
||||||
jest
|
|
||||||
.fn()
|
|
||||||
.mockImplementation(() =>
|
|
||||||
Promise.resolve({ json: () => Promise.resolve({ k: "v" }) })
|
|
||||||
);
|
|
||||||
|
|
||||||
var mockAppState = () => ({
|
|
||||||
user_data: JSON.parse(
|
|
||||||
'[{"kind":"user","name":"foo","admin":true,"groups":[],"server":"/user/foo/","pending":null,"created":"2020-12-07T18:46:27.112695Z","last_activity":"2020-12-07T21:00:33.336354Z","servers":{"":{"name":"","last_activity":"2020-12-07T20:58:02.437408Z","started":"2020-12-07T20:58:01.508266Z","pending":null,"ready":true,"state":{"pid":28085},"url":"/user/foo/","user_options":{},"progress_url":"/hub/api/users/foo/server/progress"}}},{"kind":"user","name":"bar","admin":false,"groups":[],"server":null,"pending":null,"created":"2020-12-07T18:46:27.115528Z","last_activity":"2020-12-07T20:43:51.013613Z","servers":{}}]'
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
let startAll = screen.getByTestId("start-all");
|
||||||
useSelector.mockImplementation((callback) => {
|
|
||||||
return callback(mockAppState());
|
await act(async () => {
|
||||||
});
|
fireEvent.click(startAll);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
let errorDialog = screen.getByText("Failed to start servers.");
|
||||||
useSelector.mockClear();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Renders users from props.user_data into table", () => {
|
expect(errorDialog).toBeVisible();
|
||||||
let component = mount(serverDashboardJsx(mockAsync())),
|
});
|
||||||
userRows = component.find(".user-row");
|
|
||||||
expect(userRows.length).toBe(2);
|
test("Shows a UI error dialogue when stop all servers fails", async () => {
|
||||||
});
|
let spy = mockAsync();
|
||||||
|
let rejectSpy = mockAsyncRejection;
|
||||||
it("Renders correctly the status of a single-user server", () => {
|
|
||||||
let component = mount(serverDashboardJsx(mockAsync())),
|
await act(async () => {
|
||||||
userRows = component.find(".user-row");
|
render(
|
||||||
// Renders .stop-button when server is started
|
<Provider store={createStore(() => {}, {})}>
|
||||||
// Should be 1 since user foo is started
|
<HashRouter>
|
||||||
expect(userRows.at(0).find(".stop-button").length).toBe(1);
|
<Switch>
|
||||||
// Renders .start-button when server is stopped
|
<ServerDashboard
|
||||||
// Should be 1 since user bar is stopped
|
updateUsers={spy}
|
||||||
expect(userRows.at(1).find(".start-button").length).toBe(1);
|
shutdownHub={spy}
|
||||||
});
|
startServer={spy}
|
||||||
|
stopServer={spy}
|
||||||
it("Invokes the startServer event on button click", () => {
|
startAll={spy}
|
||||||
let callbackSpy = mockAsync(),
|
stopAll={rejectSpy}
|
||||||
component = mount(serverDashboardJsx(callbackSpy)),
|
/>
|
||||||
startBtn = component.find(".start-button");
|
</Switch>
|
||||||
startBtn.simulate("click");
|
</HashRouter>
|
||||||
expect(callbackSpy).toHaveBeenCalled();
|
</Provider>
|
||||||
});
|
);
|
||||||
|
});
|
||||||
it("Invokes the stopServer event on button click", () => {
|
|
||||||
let callbackSpy = mockAsync(),
|
let stopAll = screen.getByTestId("stop-all");
|
||||||
component = mount(serverDashboardJsx(callbackSpy)),
|
|
||||||
stopBtn = component.find(".stop-button");
|
await act(async () => {
|
||||||
stopBtn.simulate("click");
|
fireEvent.click(stopAll);
|
||||||
expect(callbackSpy).toHaveBeenCalled();
|
});
|
||||||
});
|
|
||||||
|
let errorDialog = screen.getByText("Failed to stop servers.");
|
||||||
it("Invokes the shutdownHub event on button click", () => {
|
|
||||||
let callbackSpy = mockAsync(),
|
expect(errorDialog).toBeVisible();
|
||||||
component = mount(serverDashboardJsx(callbackSpy)),
|
});
|
||||||
shutdownBtn = component.find("#shutdown-button").first();
|
|
||||||
shutdownBtn.simulate("click");
|
test("Shows a UI error dialogue when start user server fails", async () => {
|
||||||
expect(callbackSpy).toHaveBeenCalled();
|
let spy = mockAsync();
|
||||||
});
|
let rejectSpy = mockAsyncRejection();
|
||||||
|
|
||||||
it("Sorts according to username", () => {
|
await act(async () => {
|
||||||
let component = mount(serverDashboardJsx(mockAsync())).find(
|
render(
|
||||||
"ServerDashboard"
|
<Provider store={createStore(() => {}, {})}>
|
||||||
),
|
<HashRouter>
|
||||||
handler = component.find("SortHandler").first();
|
<Switch>
|
||||||
handler.simulate("click");
|
<ServerDashboard
|
||||||
let first = component.find(".user-row").first();
|
updateUsers={spy}
|
||||||
expect(first.html().includes("bar")).toBe(true);
|
shutdownHub={spy}
|
||||||
handler.simulate("click");
|
startServer={rejectSpy}
|
||||||
first = component.find(".user-row").first();
|
stopServer={spy}
|
||||||
expect(first.html().includes("foo")).toBe(true);
|
startAll={spy}
|
||||||
});
|
stopAll={spy}
|
||||||
|
/>
|
||||||
it("Sorts according to admin", () => {
|
</Switch>
|
||||||
let component = mount(serverDashboardJsx(mockAsync())).find(
|
</HashRouter>
|
||||||
"ServerDashboard"
|
</Provider>
|
||||||
),
|
);
|
||||||
handler = component.find("SortHandler").at(1);
|
});
|
||||||
handler.simulate("click");
|
|
||||||
let first = component.find(".user-row").first();
|
let start = screen.getByText("Start Server");
|
||||||
expect(first.html().includes("admin")).toBe(true);
|
|
||||||
handler.simulate("click");
|
await act(async () => {
|
||||||
first = component.find(".user-row").first();
|
fireEvent.click(start);
|
||||||
expect(first.html().includes("admin")).toBe(false);
|
});
|
||||||
});
|
|
||||||
|
let errorDialog = screen.getByText("Failed to start server.");
|
||||||
it("Sorts according to last activity", () => {
|
|
||||||
let component = mount(serverDashboardJsx(mockAsync())).find(
|
expect(errorDialog).toBeVisible();
|
||||||
"ServerDashboard"
|
});
|
||||||
),
|
|
||||||
handler = component.find("SortHandler").at(2);
|
test("Shows a UI error dialogue when start user server returns an improper status code", async () => {
|
||||||
handler.simulate("click");
|
let spy = mockAsync();
|
||||||
let first = component.find(".user-row").first();
|
let rejectSpy = mockAsync({ status: 403 });
|
||||||
// foo used most recently
|
|
||||||
expect(first.html().includes("foo")).toBe(true);
|
await act(async () => {
|
||||||
handler.simulate("click");
|
render(
|
||||||
first = component.find(".user-row").first();
|
<Provider store={createStore(() => {}, {})}>
|
||||||
// invert sort - bar used least recently
|
<HashRouter>
|
||||||
expect(first.html().includes("bar")).toBe(true);
|
<Switch>
|
||||||
});
|
<ServerDashboard
|
||||||
|
updateUsers={spy}
|
||||||
it("Sorts according to server status (running/not running)", () => {
|
shutdownHub={spy}
|
||||||
let component = mount(serverDashboardJsx(mockAsync())).find(
|
startServer={rejectSpy}
|
||||||
"ServerDashboard"
|
stopServer={spy}
|
||||||
),
|
startAll={spy}
|
||||||
handler = component.find("SortHandler").at(3);
|
stopAll={spy}
|
||||||
handler.simulate("click");
|
/>
|
||||||
let first = component.find(".user-row").first();
|
</Switch>
|
||||||
// foo running
|
</HashRouter>
|
||||||
expect(first.html().includes("foo")).toBe(true);
|
</Provider>
|
||||||
handler.simulate("click");
|
);
|
||||||
first = component.find(".user-row").first();
|
});
|
||||||
// invert sort - bar not running
|
|
||||||
expect(first.html().includes("bar")).toBe(true);
|
let start = screen.getByText("Start Server");
|
||||||
});
|
|
||||||
|
await act(async () => {
|
||||||
it("Renders nothing if required data is not available", () => {
|
fireEvent.click(start);
|
||||||
useSelector.mockImplementation((callback) => {
|
});
|
||||||
return callback({});
|
|
||||||
});
|
let errorDialog = screen.getByText("Failed to start server.");
|
||||||
let component = mount(serverDashboardJsx(jest.fn()));
|
|
||||||
expect(component.html()).toBe("<div></div>");
|
expect(errorDialog).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Shows a UI error dialogue when stop user servers fails", async () => {
|
||||||
|
let spy = mockAsync();
|
||||||
|
let rejectSpy = mockAsyncRejection();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
render(
|
||||||
|
<Provider store={createStore(() => {}, {})}>
|
||||||
|
<HashRouter>
|
||||||
|
<Switch>
|
||||||
|
<ServerDashboard
|
||||||
|
updateUsers={spy}
|
||||||
|
shutdownHub={spy}
|
||||||
|
startServer={spy}
|
||||||
|
stopServer={rejectSpy}
|
||||||
|
startAll={spy}
|
||||||
|
stopAll={spy}
|
||||||
|
/>
|
||||||
|
</Switch>
|
||||||
|
</HashRouter>
|
||||||
|
</Provider>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
let stop = screen.getByText("Stop Server");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(stop);
|
||||||
|
});
|
||||||
|
|
||||||
|
let errorDialog = screen.getByText("Failed to stop server.");
|
||||||
|
|
||||||
|
expect(errorDialog).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Shows a UI error dialogue when stop user server returns an improper status code", async () => {
|
||||||
|
let spy = mockAsync();
|
||||||
|
let rejectSpy = mockAsync({ status: 403 });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
render(
|
||||||
|
<Provider store={createStore(() => {}, {})}>
|
||||||
|
<HashRouter>
|
||||||
|
<Switch>
|
||||||
|
<ServerDashboard
|
||||||
|
updateUsers={spy}
|
||||||
|
shutdownHub={spy}
|
||||||
|
startServer={spy}
|
||||||
|
stopServer={rejectSpy}
|
||||||
|
startAll={spy}
|
||||||
|
stopAll={spy}
|
||||||
|
/>
|
||||||
|
</Switch>
|
||||||
|
</HashRouter>
|
||||||
|
</Provider>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
let stop = screen.getByText("Stop Server");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(stop);
|
||||||
|
});
|
||||||
|
|
||||||
|
let errorDialog = screen.getByText("Failed to stop server.");
|
||||||
|
|
||||||
|
expect(errorDialog).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Search for user calls updateUsers with name filter", async () => {
|
||||||
|
let spy = mockAsync();
|
||||||
|
let mockUpdateUsers = jest.fn((offset, limit, name_filter) => {
|
||||||
|
return Promise.resolve([]);
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
render(
|
||||||
|
<Provider store={createStore(() => {}, {})}>
|
||||||
|
<HashRouter>
|
||||||
|
<Switch>
|
||||||
|
<ServerDashboard
|
||||||
|
updateUsers={mockUpdateUsers}
|
||||||
|
shutdownHub={spy}
|
||||||
|
startServer={spy}
|
||||||
|
stopServer={spy}
|
||||||
|
startAll={spy}
|
||||||
|
stopAll={spy}
|
||||||
|
/>
|
||||||
|
</Switch>
|
||||||
|
</HashRouter>
|
||||||
|
</Provider>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
let search = screen.getByLabelText("user-search");
|
||||||
|
|
||||||
|
userEvent.type(search, "a");
|
||||||
|
expect(search.value).toEqual("a");
|
||||||
|
clock.tick(400);
|
||||||
|
expect(mockUpdateUsers.mock.calls[1][2]).toEqual("a");
|
||||||
|
expect(mockUpdateUsers.mock.calls).toHaveLength(2);
|
||||||
|
|
||||||
|
userEvent.type(search, "b");
|
||||||
|
expect(search.value).toEqual("ab");
|
||||||
|
clock.tick(400);
|
||||||
|
expect(mockUpdateUsers.mock.calls[2][2]).toEqual("ab");
|
||||||
|
expect(mockUpdateUsers.mock.calls).toHaveLength(3);
|
||||||
});
|
});
|
||||||
|
@@ -1,5 +1,7 @@
|
|||||||
export const jhapiRequest = (endpoint, method, data) => {
|
export const jhapiRequest = (endpoint, method, data) => {
|
||||||
return fetch("/hub/api" + endpoint, {
|
let base_url = window.base_url || "/",
|
||||||
|
api_url = `${base_url}hub/api`;
|
||||||
|
return fetch(api_url + endpoint, {
|
||||||
method: method,
|
method: method,
|
||||||
json: true,
|
json: true,
|
||||||
headers: {
|
headers: {
|
||||||
|
@@ -2,17 +2,22 @@ import { withProps } from "recompose";
|
|||||||
import { jhapiRequest } from "./jhapiUtil";
|
import { jhapiRequest } from "./jhapiUtil";
|
||||||
|
|
||||||
const withAPI = withProps(() => ({
|
const withAPI = withProps(() => ({
|
||||||
updateUsers: (offset, limit) =>
|
updateUsers: (offset, limit, name_filter) =>
|
||||||
jhapiRequest(`/users?offset=${offset}&limit=${limit}`, "GET").then((data) =>
|
jhapiRequest(
|
||||||
data.json()
|
`/users?include_stopped_servers&offset=${offset}&limit=${limit}&name_filter=${
|
||||||
),
|
name_filter || ""
|
||||||
|
}`,
|
||||||
|
"GET"
|
||||||
|
).then((data) => data.json()),
|
||||||
updateGroups: (offset, limit) =>
|
updateGroups: (offset, limit) =>
|
||||||
jhapiRequest(`/groups?offset=${offset}&limit=${limit}`, "GET").then(
|
jhapiRequest(`/groups?offset=${offset}&limit=${limit}`, "GET").then(
|
||||||
(data) => data.json()
|
(data) => data.json()
|
||||||
),
|
),
|
||||||
shutdownHub: () => jhapiRequest("/shutdown", "POST"),
|
shutdownHub: () => jhapiRequest("/shutdown", "POST"),
|
||||||
startServer: (name) => jhapiRequest("/users/" + name + "/server", "POST"),
|
startServer: (name, serverName = "") =>
|
||||||
stopServer: (name) => jhapiRequest("/users/" + name + "/server", "DELETE"),
|
jhapiRequest("/users/" + name + "/servers/" + (serverName || ""), "POST"),
|
||||||
|
stopServer: (name, serverName = "") =>
|
||||||
|
jhapiRequest("/users/" + name + "/servers/" + (serverName || ""), "DELETE"),
|
||||||
startAll: (names) =>
|
startAll: (names) =>
|
||||||
names.map((e) => jhapiRequest("/users/" + e + "/server", "POST")),
|
names.map((e) => jhapiRequest("/users/" + e + "/server", "POST")),
|
||||||
stopAll: (names) =>
|
stopAll: (names) =>
|
||||||
@@ -36,13 +41,14 @@ const withAPI = withProps(() => ({
|
|||||||
jhapiRequest("/users/" + username, "GET")
|
jhapiRequest("/users/" + username, "GET")
|
||||||
.then((data) => data.status)
|
.then((data) => data.status)
|
||||||
.then((data) => (data > 200 ? false : true)),
|
.then((data) => (data > 200 ? false : true)),
|
||||||
failRegexEvent: () =>
|
// Temporarily Unused
|
||||||
alert(
|
failRegexEvent: () => {
|
||||||
"Cannot change username - either contains special characters or is too short."
|
return null;
|
||||||
),
|
|
||||||
noChangeEvent: () => {
|
|
||||||
returns;
|
|
||||||
},
|
},
|
||||||
|
noChangeEvent: () => {
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
//
|
||||||
refreshGroupsData: () =>
|
refreshGroupsData: () =>
|
||||||
jhapiRequest("/groups", "GET").then((data) => data.json()),
|
jhapiRequest("/groups", "GET").then((data) => data.json()),
|
||||||
refreshUserData: () =>
|
refreshUserData: () =>
|
||||||
|
@@ -1,6 +1,5 @@
|
|||||||
const webpack = require("webpack");
|
const webpack = require("webpack");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
const express = require("express");
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
entry: path.resolve(__dirname, "src", "App.jsx"),
|
entry: path.resolve(__dirname, "src", "App.jsx"),
|
||||||
@@ -34,16 +33,19 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
plugins: [new webpack.HotModuleReplacementPlugin()],
|
plugins: [new webpack.HotModuleReplacementPlugin()],
|
||||||
devServer: {
|
devServer: {
|
||||||
contentBase: path.resolve(__dirname, "build"),
|
static: {
|
||||||
|
directory: path.resolve(__dirname, "build"),
|
||||||
|
},
|
||||||
port: 9000,
|
port: 9000,
|
||||||
before: (app, server) => {
|
onBeforeSetupMiddleware: (devServer) => {
|
||||||
|
const app = devServer.app;
|
||||||
|
|
||||||
var user_data = JSON.parse(
|
var user_data = JSON.parse(
|
||||||
'[{"kind":"user","name":"foo","admin":true,"groups":[],"server":"/user/foo/","pending":null,"created":"2020-12-07T18:46:27.112695Z","last_activity":"2020-12-07T21:00:33.336354Z","servers":{"":{"name":"","last_activity":"2020-12-07T20:58:02.437408Z","started":"2020-12-07T20:58:01.508266Z","pending":null,"ready":true,"state":{"pid":28085},"url":"/user/foo/","user_options":{},"progress_url":"/hub/api/users/foo/server/progress"}}},{"kind":"user","name":"bar","admin":false,"groups":[],"server":null,"pending":null,"created":"2020-12-07T18:46:27.115528Z","last_activity":"2020-12-07T20:43:51.013613Z","servers":{}}]'
|
'[{"kind":"user","name":"foo","admin":true,"groups":[],"server":"/user/foo/","pending":null,"created":"2020-12-07T18:46:27.112695Z","last_activity":"2020-12-07T21:00:33.336354Z","servers":{"":{"name":"","last_activity":"2020-12-07T20:58:02.437408Z","started":"2020-12-07T20:58:01.508266Z","pending":null,"ready":true,"state":{"pid":28085},"url":"/user/foo/","user_options":{},"progress_url":"/hub/api/users/foo/server/progress"}}},{"kind":"user","name":"bar","admin":false,"groups":[],"server":null,"pending":null,"created":"2020-12-07T18:46:27.115528Z","last_activity":"2020-12-07T20:43:51.013613Z","servers":{}}]'
|
||||||
);
|
);
|
||||||
var group_data = JSON.parse(
|
var group_data = JSON.parse(
|
||||||
'[{"kind":"group","name":"testgroup","users":[]}, {"kind":"group","name":"testgroup2","users":["foo", "bar"]}]'
|
'[{"kind":"group","name":"testgroup","users":[]}, {"kind":"group","name":"testgroup2","users":["foo", "bar"]}]'
|
||||||
);
|
);
|
||||||
app.use(express.json());
|
|
||||||
|
|
||||||
// get user_data
|
// get user_data
|
||||||
app.get("/hub/api/users", (req, res) => {
|
app.get("/hub/api/users", (req, res) => {
|
||||||
|
6769
jsx/yarn.lock
6769
jsx/yarn.lock
File diff suppressed because it is too large
Load Diff
@@ -1,2 +1 @@
|
|||||||
from ._version import __version__
|
from ._version import __version__, version_info
|
||||||
from ._version import version_info
|
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user