mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-18 15:33:02 +00:00
server-side sorting of admin page
removes in-page sort, which removes sort by server name, sort by running Running column switches from sort to filter, matching the `?state` query parameter in the API needs some CSS on the column widths to avoid jumps when toggling active servers
This commit is contained in:
13
jsx/package-lock.json
generated
13
jsx/package-lock.json
generated
@@ -14,7 +14,7 @@
|
|||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"prop-types": "^15.8.1",
|
"prop-types": "^15.8.1",
|
||||||
"react": "^17.0.2",
|
"react": "^17.0.2",
|
||||||
"react-bootstrap": "^2.7.4",
|
"react-bootstrap": "^2.10.1",
|
||||||
"react-dom": "^17.0.2",
|
"react-dom": "^17.0.2",
|
||||||
"react-icons": "^4.8.0",
|
"react-icons": "^4.8.0",
|
||||||
"react-multi-select-component": "^4.3.4",
|
"react-multi-select-component": "^4.3.4",
|
||||||
@@ -8209,13 +8209,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-bootstrap": {
|
"node_modules/react-bootstrap": {
|
||||||
"version": "2.7.4",
|
"version": "2.10.1",
|
||||||
"license": "MIT",
|
"resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-2.10.1.tgz",
|
||||||
|
"integrity": "sha512-J3OpRZIvCTQK+Tg/jOkRUvpYLHMdGeU9KqFUBQrV0d/Qr/3nsINpiOJyZMWnM5SJ3ctZdhPA6eCIKpEJR3Ellg==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.21.0",
|
"@babel/runtime": "^7.22.5",
|
||||||
"@restart/hooks": "^0.4.9",
|
"@restart/hooks": "^0.4.9",
|
||||||
"@restart/ui": "^1.6.3",
|
"@restart/ui": "^1.6.6",
|
||||||
"@types/react-transition-group": "^4.4.5",
|
"@types/react-transition-group": "^4.4.6",
|
||||||
"classnames": "^2.3.2",
|
"classnames": "^2.3.2",
|
||||||
"dom-helpers": "^5.2.1",
|
"dom-helpers": "^5.2.1",
|
||||||
"invariant": "^2.2.4",
|
"invariant": "^2.2.4",
|
||||||
|
@@ -34,7 +34,7 @@
|
|||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"prop-types": "^15.8.1",
|
"prop-types": "^15.8.1",
|
||||||
"react": "^17.0.2",
|
"react": "^17.0.2",
|
||||||
"react-bootstrap": "^2.7.4",
|
"react-bootstrap": "^2.10.1",
|
||||||
"react-dom": "^17.0.2",
|
"react-dom": "^17.0.2",
|
||||||
"react-icons": "^4.8.0",
|
"react-icons": "^4.8.0",
|
||||||
"react-multi-select-component": "^4.3.4",
|
"react-multi-select-component": "^4.3.4",
|
||||||
|
@@ -7,6 +7,7 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Col,
|
Col,
|
||||||
Row,
|
Row,
|
||||||
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
Card,
|
Card,
|
||||||
CardGroup,
|
CardGroup,
|
||||||
@@ -32,21 +33,6 @@ RowListItem.propTypes = {
|
|||||||
const ServerDashboard = (props) => {
|
const ServerDashboard = (props) => {
|
||||||
const base_url = window.base_url || "/";
|
const base_url = window.base_url || "/";
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
// sort methods
|
|
||||||
const usernameDesc = (e) => e.sort((a, b) => (a.name > b.name ? 1 : -1)),
|
|
||||||
usernameAsc = (e) => e.sort((a, b) => (a.name < b.name ? 1 : -1)),
|
|
||||||
adminDesc = (e) => e.sort((a) => (a.admin ? -1 : 1)),
|
|
||||||
adminAsc = (e) => e.sort((a) => (a.admin ? 1 : -1)),
|
|
||||||
dateDesc = (e) =>
|
|
||||||
e.sort((a, b) =>
|
|
||||||
new Date(a.last_activity) - new Date(b.last_activity) > 0 ? -1 : 1,
|
|
||||||
),
|
|
||||||
dateAsc = (e) =>
|
|
||||||
e.sort((a, b) =>
|
|
||||||
new Date(a.last_activity) - new Date(b.last_activity) > 0 ? 1 : -1,
|
|
||||||
),
|
|
||||||
runningAsc = (e) => e.sort((a) => (a.server == null ? -1 : 1)),
|
|
||||||
runningDesc = (e) => e.sort((a) => (a.server == null ? 1 : -1));
|
|
||||||
|
|
||||||
const [errorAlert, setErrorAlert] = useState(null);
|
const [errorAlert, setErrorAlert] = useState(null);
|
||||||
const [sortMethod, setSortMethod] = useState(null);
|
const [sortMethod, setSortMethod] = useState(null);
|
||||||
@@ -59,6 +45,8 @@ const ServerDashboard = (props) => {
|
|||||||
usePaginationParams();
|
usePaginationParams();
|
||||||
|
|
||||||
const name_filter = searchParams.get("name_filter") || "";
|
const name_filter = searchParams.get("name_filter") || "";
|
||||||
|
const sort = searchParams.get("sort") || "id";
|
||||||
|
const state_filter = searchParams.get("state") || "";
|
||||||
|
|
||||||
const total = user_page ? user_page.total : undefined;
|
const total = user_page ? user_page.total : undefined;
|
||||||
|
|
||||||
@@ -76,7 +64,13 @@ const ServerDashboard = (props) => {
|
|||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const dispatchPageUpdate = (data, page) => {
|
const dispatchPageUpdate = (data, page) => {
|
||||||
|
// trigger page update in state
|
||||||
|
// in response to fetching updated user list
|
||||||
|
// data is list of user records
|
||||||
|
// page is _pagination part of response
|
||||||
|
// persist page info in url query
|
||||||
setPagination(page);
|
setPagination(page);
|
||||||
|
// persist user data, triggers rerender
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "USER_PAGE",
|
type: "USER_PAGE",
|
||||||
value: {
|
value: {
|
||||||
@@ -87,6 +81,8 @@ const ServerDashboard = (props) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const setNameFilter = (new_name_filter) => {
|
const setNameFilter = (new_name_filter) => {
|
||||||
|
// persist ?name_filter parameter
|
||||||
|
// store in url param, clear when value is empty
|
||||||
setSearchParams((params) => {
|
setSearchParams((params) => {
|
||||||
// clear offset when name filter changes
|
// clear offset when name filter changes
|
||||||
if (new_name_filter !== name_filter) {
|
if (new_name_filter !== name_filter) {
|
||||||
@@ -101,26 +97,56 @@ const ServerDashboard = (props) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const setSort = (sort) => {
|
||||||
|
// persist ?sort parameter
|
||||||
|
// store in url param, clear when value is default ('id')
|
||||||
|
setSearchParams((params) => {
|
||||||
|
if (sort === "id") {
|
||||||
|
params.delete("id");
|
||||||
|
} else {
|
||||||
|
params.set("sort", sort);
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const setStateFilter = (state_filter) => {
|
||||||
|
// persist ?state filter
|
||||||
|
// store in url param, clear when value is default ('')
|
||||||
|
setSearchParams((params) => {
|
||||||
|
if (!state_filter) {
|
||||||
|
params.delete("state");
|
||||||
|
} else {
|
||||||
|
params.set("state", state_filter);
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// the callback to update the displayed user list
|
||||||
|
const updateUsersWithParams = () =>
|
||||||
|
updateUsers({
|
||||||
|
offset,
|
||||||
|
limit,
|
||||||
|
name_filter,
|
||||||
|
sort,
|
||||||
|
state: state_filter,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
updateUsers(offset, limit, name_filter)
|
updateUsersWithParams()
|
||||||
.then((data) => dispatchPageUpdate(data.items, data._pagination))
|
.then((data) => dispatchPageUpdate(data.items, data._pagination))
|
||||||
.catch((err) => setErrorAlert("Failed to update user list."));
|
.catch((err) => setErrorAlert("Failed to update user list."));
|
||||||
}, [offset, limit, name_filter]);
|
}, [offset, limit, name_filter, sort, state_filter]);
|
||||||
|
|
||||||
if (!user_data || !user_page) {
|
if (!user_data || !user_page) {
|
||||||
return <div data-testid="no-show"></div>;
|
return <div data-testid="no-show"></div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
var slice = [offset, limit, name_filter];
|
|
||||||
|
|
||||||
const handleSearch = debounce(async (event) => {
|
const handleSearch = debounce(async (event) => {
|
||||||
setNameFilter(event.target.value);
|
setNameFilter(event.target.value);
|
||||||
}, 300);
|
}, 300);
|
||||||
|
|
||||||
if (sortMethod != null) {
|
|
||||||
user_data = sortMethod(user_data);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ServerButton = ({ server, user, action, name, extraClass }) => {
|
const ServerButton = ({ server, user, action, name, extraClass }) => {
|
||||||
var [isDisabled, setIsDisabled] = useState(false);
|
var [isDisabled, setIsDisabled] = useState(false);
|
||||||
return (
|
return (
|
||||||
@@ -132,7 +158,7 @@ const ServerDashboard = (props) => {
|
|||||||
action(user.name, server.name)
|
action(user.name, server.name)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (res.status < 300) {
|
if (res.status < 300) {
|
||||||
updateUsers(...slice)
|
updateUsersWithParams()
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
dispatchPageUpdate(data.items, data._pagination);
|
dispatchPageUpdate(data.items, data._pagination);
|
||||||
})
|
})
|
||||||
@@ -417,42 +443,39 @@ const ServerDashboard = (props) => {
|
|||||||
<th id="user-header">
|
<th id="user-header">
|
||||||
User{" "}
|
User{" "}
|
||||||
<SortHandler
|
<SortHandler
|
||||||
sorts={{ asc: usernameAsc, desc: usernameDesc }}
|
currentSort={sort}
|
||||||
callback={(method) => setSortMethod(() => method)}
|
setSort={setSort}
|
||||||
|
sortKey="name"
|
||||||
testid="user-sort"
|
testid="user-sort"
|
||||||
/>
|
/>
|
||||||
</th>
|
</th>
|
||||||
<th id="admin-header">
|
<th id="admin-header">Admin</th>
|
||||||
Admin{" "}
|
<th id="server-header">Server</th>
|
||||||
<SortHandler
|
|
||||||
sorts={{ asc: adminAsc, desc: adminDesc }}
|
|
||||||
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 id="last-activity-header">
|
<th id="last-activity-header">
|
||||||
Last Activity{" "}
|
Last Activity{" "}
|
||||||
<SortHandler
|
<SortHandler
|
||||||
sorts={{ asc: dateAsc, desc: dateDesc }}
|
currentSort={sort}
|
||||||
callback={(method) => setSortMethod(() => method)}
|
setSort={setSort}
|
||||||
|
sortKey="last_activity"
|
||||||
testid="last-activity-sort"
|
testid="last-activity-sort"
|
||||||
/>
|
/>
|
||||||
</th>
|
</th>
|
||||||
<th id="running-status-header">
|
<th id="running-status-header">
|
||||||
Running{" "}
|
<label title="only show active servers">
|
||||||
<SortHandler
|
<Form.Check
|
||||||
sorts={{ asc: runningAsc, desc: runningDesc }}
|
inline
|
||||||
callback={(method) => setSortMethod(() => method)}
|
type="checkbox"
|
||||||
testid="running-status-sort"
|
name="active_servers"
|
||||||
/>
|
aria-label="only show active servers"
|
||||||
|
checked={state_filter == "active"}
|
||||||
|
onChange={(event) => {
|
||||||
|
setStateFilter(event.target.checked ? "active" : null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{state_filter == "active"
|
||||||
|
? " Active servers"
|
||||||
|
: " All servers"}
|
||||||
|
</label>
|
||||||
</th>
|
</th>
|
||||||
<th id="actions-header">Actions</th>
|
<th id="actions-header">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -490,7 +513,7 @@ const ServerDashboard = (props) => {
|
|||||||
return res;
|
return res;
|
||||||
})
|
})
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
updateUsers(...slice)
|
updateUsersWithParams()
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
dispatchPageUpdate(data.items, data._pagination);
|
dispatchPageUpdate(data.items, data._pagination);
|
||||||
})
|
})
|
||||||
@@ -526,7 +549,7 @@ const ServerDashboard = (props) => {
|
|||||||
return res;
|
return res;
|
||||||
})
|
})
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
updateUsers(...slice)
|
updateUsersWithParams()
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
dispatchPageUpdate(data.items, data._pagination);
|
dispatchPageUpdate(data.items, data._pagination);
|
||||||
})
|
})
|
||||||
@@ -590,30 +613,27 @@ ServerDashboard.propTypes = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const SortHandler = (props) => {
|
const SortHandler = (props) => {
|
||||||
var { sorts, callback, testid } = props;
|
const { currentSort, setSort, sortKey, testid } = props;
|
||||||
|
|
||||||
var [direction, setDirection] = useState(undefined);
|
|
||||||
|
|
||||||
|
const currentlySorted = currentSort && currentSort.endsWith(sortKey);
|
||||||
|
const descending = currentSort && currentSort.startsWith("-");
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="sort-icon"
|
className="sort-icon"
|
||||||
data-testid={testid}
|
data-testid={testid}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!direction) {
|
if (!currentlySorted) {
|
||||||
callback(sorts.desc);
|
setSort(sortKey);
|
||||||
setDirection("desc");
|
} else if (descending) {
|
||||||
} else if (direction == "asc") {
|
setSort(sortKey);
|
||||||
callback(sorts.desc);
|
|
||||||
setDirection("desc");
|
|
||||||
} else {
|
} else {
|
||||||
callback(sorts.asc);
|
setSort("-" + sortKey);
|
||||||
setDirection("asc");
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{!direction ? (
|
{!currentlySorted ? (
|
||||||
<FaSort />
|
<FaSort />
|
||||||
) : direction == "asc" ? (
|
) : descending ? (
|
||||||
<FaSortDown />
|
<FaSortDown />
|
||||||
) : (
|
) : (
|
||||||
<FaSortUp />
|
<FaSortUp />
|
||||||
@@ -623,8 +643,9 @@ const SortHandler = (props) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
SortHandler.propTypes = {
|
SortHandler.propTypes = {
|
||||||
sorts: PropTypes.object,
|
currentSort: PropTypes.string,
|
||||||
callback: PropTypes.func,
|
setSort: PropTypes.func,
|
||||||
|
sortKey: PropTypes.string,
|
||||||
testid: PropTypes.string,
|
testid: PropTypes.string,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@@ -46,3 +46,51 @@ tr.noborder > td {
|
|||||||
.user-row .actions > * {
|
.user-row .actions > * {
|
||||||
margin-right: 5px;
|
margin-right: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-check-inline {
|
||||||
|
/* inline form check doesn't get inline css
|
||||||
|
I _think_ this is because our bootstrap css is outdated
|
||||||
|
*/
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* column widths for dashboard
|
||||||
|
|
||||||
|
goals:
|
||||||
|
|
||||||
|
- want stable width for running-status
|
||||||
|
so clicking the running filter doesn't cause a jump
|
||||||
|
- shrink fixed-content columns (action, admin)
|
||||||
|
- allow variable content columns (username, server name)
|
||||||
|
to claim remaining space
|
||||||
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
.admin-table-head label {
|
||||||
|
/* clear margin-bottom to keep label on baseline */
|
||||||
|
margin-bottom: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table-head #user-header {
|
||||||
|
}
|
||||||
|
.admin-table-head #admin-header {
|
||||||
|
width: 64px;
|
||||||
|
}
|
||||||
|
.admin-table-head #server-header {
|
||||||
|
}
|
||||||
|
.admin-table-head #last-activity-header {
|
||||||
|
width: 180px;
|
||||||
|
}
|
||||||
|
.admin-table-head #running-status-header {
|
||||||
|
width: 300px;
|
||||||
|
}
|
||||||
|
.admin-table-head #actions-header {
|
||||||
|
width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* vertical stack server buttons on small windows */
|
||||||
|
@media (max-width: 991px) {
|
||||||
|
.admin-table-head #running-status-header {
|
||||||
|
width: 140px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@@ -2,13 +2,16 @@ import { withProps } from "recompose";
|
|||||||
import { jhapiRequest } from "./jhapiUtil";
|
import { jhapiRequest } from "./jhapiUtil";
|
||||||
|
|
||||||
const withAPI = withProps(() => ({
|
const withAPI = withProps(() => ({
|
||||||
updateUsers: (offset, limit, name_filter) =>
|
updateUsers: (options) => {
|
||||||
jhapiRequest(
|
let params = new URLSearchParams();
|
||||||
`/users?include_stopped_servers&offset=${offset}&limit=${limit}&name_filter=${
|
params["include_stopped_servers"] = "1";
|
||||||
name_filter || ""
|
for (let key in options) {
|
||||||
}`,
|
params.set(key, options[key]);
|
||||||
"GET",
|
}
|
||||||
).then((data) => data.json()),
|
return jhapiRequest(`/users?${params.toString()}`, "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(),
|
||||||
|
Reference in New Issue
Block a user