Enable sorting of account list by column

This commit is contained in:
Chord 2019-12-31 15:25:35 -05:00
parent 5c11393dae
commit c54b163be2
7 changed files with 333 additions and 33 deletions

View file

@ -1,6 +1,6 @@
import express from 'express'
import * as db from './db.js'
import { get_pagination, fetch_user_middleware } from './util.js'
import { get_pagination, get_filter, get_sort, fetch_user_middleware } from './util.js'
const api = express.Router();
@ -8,9 +8,32 @@ api.param("user", fetch_user_middleware);
api.get('/users', async (req, res, next) => {
const pagination = get_pagination(req);
const filter = get_filter(req,
{
param : 'filter',
default : 'all',
types : {
'gm' : { [db.ACCOUNT.ADMIN] : true },
'banned' : { [db.ACCOUNT.BANNED] : true },
}
}
);
const sort = get_sort(req,
{
param : 'sort',
default : 'created_desc',
types : {
'id' : db.ACCOUNT.ID,
'created' : db.ACCOUNT.CREATED,
'username' : db.ACCOUNT.USER,
'last_login' : db.ACCOUNT.LAST_LOGIN,
}
}
);
try {
const accounts = await db.get_accounts_login_info(pagination, db.ACCOUNT.CREATED, db.SQL_ORDER.DESCENDING);
const accounts = await db.get_accounts_login_info(pagination, sort, filter);
res.status(200).json({ users: accounts, page: pagination})
} catch (e) {
console.log(e)

View file

@ -39,6 +39,9 @@ export const ACCOUNT = Object.freeze({
MODIFIED: Symbol("last_modified"),
BANNED: Symbol("inactive"),
ADMIN: Symbol("gm"),
// A derived table column
LAST_LOGIN: Symbol("last_login"),
});
export const CHARACTER = Object.freeze({
@ -63,7 +66,8 @@ export const LOGIN = Object.freeze({
});
function to_sql(symbol) {
assert(typeof symbol == 'symbol')
assert(typeof symbol == 'symbol',
`symbol expected got ${typeof symbol}`)
return String(symbol).slice(7,-1);
}
@ -72,7 +76,9 @@ function to_sql_kv(fields, idx=1) {
let values = [];
// This will ONLY get Symbols in the field dict
assert(Object.getOwnPropertySymbols(fields).length > 0, "to_sql_kv must have at least one field")
if (!fields || Object.getOwnPropertySymbols(fields).length == 0) {
return { sql : [], next_idx: idx, values: [] }
}
Object.getOwnPropertySymbols(fields).forEach(key => {
assert(typeof key == 'symbol')
@ -89,22 +95,64 @@ function to_sql_kv(fields, idx=1) {
function build_SET(fields, idx=1) {
const kv = to_sql_kv(fields, idx);
kv.sql = Symbol(kv.sql.join(", "));
assert(kv.sql.length > 0, "SET MUST have at least one kv pair")
kv.sql = Symbol(`SET ${kv.sql.join(", ")}`);
return kv;
}
function build_WHERE(fields, idx=1) {
const kv = to_sql_kv(fields, idx);
kv.sql = Symbol(kv.sql.join(" AND "));
function build_WHERE(filter, idx=1) {
const kv = to_sql_kv(filter.fields, idx);
if (kv.sql.length > 1) {
assert(filter.binop !== undefined,
"binary operand required in WHERE with multiple terms")
const binop = filter.binop == "AND" ? Symbol("AND") : Symbol("OR");
kv.sql = Symbol(`WHERE ${kv.sql.join(` ${to_sql(binop)} `)}`);
} else if (kv.sql.length == 1)
kv.sql = Symbol(`WHERE ${kv.sql[0]}`);
else
kv.sql = Symbol("")
return kv;
}
function build_ORDER_BY(sort) {
// This will ONLY get Symbols in the sort dict
if (!sort || Object.getOwnPropertySymbols(sort).length == 0) {
return Symbol("");
}
let SQL = []
Object.getOwnPropertySymbols(sort).forEach(key => {
const value = sort[key];
assert(typeof key == 'symbol')
assert(typeof value == 'symbol')
SQL.push(`${to_sql(key)} ${to_sql(value)}`);
});
return Symbol(`ORDER BY ${SQL.join(", ")}`);
}
function build_OFFSET(offset, limit, idx=1) {
assert(typeof offset == 'number', "offset must be a number");
assert(typeof limit == 'number', "limit must be a number");
return {
sql: Symbol(`OFFSET $${idx++} LIMIT $${idx++}`),
next_idx: idx,
values: [offset, limit],
};
}
async function get_row_count(table, filter=undefined) {
let resp;
if (filter) {
const where = build_WHERE(filter);
resp = await pool.query(`SELECT COUNT(*) FROM ${to_sql(table)} WHERE ${to_sql(where.sql)}`,
resp = await pool.query(`SELECT COUNT(*) FROM ${to_sql(table)} ${to_sql(where.sql)}`,
where.values);
} else {
resp = await pool.query(`SELECT COUNT(*) FROM ${to_sql(table)}`);
@ -171,17 +219,21 @@ export async function get_accounts(pagination, sort, order) {
}
}
export async function get_accounts_login_info(pagination, sort, order) {
export async function get_accounts_login_info(pagination, sort, filter) {
const start_id = (pagination.page-1)*pagination.items_per_page;
const values = [start_id, pagination.items_per_page];
try {
const account_count = await get_row_count(ACCOUNT.THIS);
const account_count = await get_row_count(ACCOUNT.THIS, filter);
const where = build_WHERE(filter);
const offset = build_OFFSET(start_id, pagination.items_per_page, where.next_idx);
const values = [].concat(where.values, offset.values);
const order = build_ORDER_BY(sort);
// this was a really hard query to get right...
// https://www.gab.lc/articles/better_faster_subqueries_postgresql/
const accounts = await pool.query(
'SELECT accounts.*, lastLogin as login_time, l2.ip_address, l2.canonical_hostname FROM accounts' +
'SELECT accounts.*, COALESCE(l.lastLogin, TIMESTAMP \'epoch\') as last_login, l2.ip_address, l2.canonical_hostname FROM accounts' +
' LEFT OUTER JOIN (' +
' SELECT MAX(id) as loginId, account_id, MAX(login_time) as lastLogin' +
' FROM logins' +
@ -189,7 +241,9 @@ export async function get_accounts_login_info(pagination, sort, order) {
' ) l ON l.account_id = accounts.id' +
' LEFT OUTER JOIN logins l2' +
' ON l2.id = l.loginId' +
` ORDER BY COALESCE(l.lastLogin, TIMESTAMP \'epoch\') ${to_sql(order)} OFFSET $1 LIMIT $2`, values);
` ${to_sql(where.sql)}` +
` ${to_sql(order)}` +
` ${to_sql(offset.sql)}`, values);
pagination.item_count = account_count;
pagination.page_count = Math.ceil(pagination.item_count / pagination.items_per_page);
@ -198,9 +252,9 @@ export async function get_accounts_login_info(pagination, sort, order) {
r.name = r.username;
r.admin = r.gm;
if (r.login_time !== null) {
if (r.ip_address !== null) {
r.last_login = {
time : r.login_time,
time : r.last_login,
hostname : r.canonical_hostname,
ip : r.ip_address,
}
@ -208,7 +262,6 @@ export async function get_accounts_login_info(pagination, sort, order) {
r.last_login = {}
}
delete r.login_time;
delete r.canonical_hostname;
delete r.ip_address;
delete r.passhash;
@ -285,7 +338,7 @@ export async function update_account(account_id, fields) {
set.values.push(account_id)
try {
const update_result = await pool.query(`UPDATE accounts SET ${to_sql(set.sql)} WHERE id=$${set.next_idx}`, set.values);
const update_result = await pool.query(`UPDATE accounts ${to_sql(set.sql)} WHERE id=$${set.next_idx}`, set.values);
return update_result.rowCount;
} catch (e) {
if (e.code)
@ -324,7 +377,11 @@ export async function get_account_logins(account_id, pagination) {
const values = [account_id, start_id, pagination.items_per_page];
try {
const login_count = await get_row_count(LOGIN.THIS, { [LOGIN.ACCOUNT_ID] : account_id });
const login_count = await get_row_count(LOGIN.THIS, {
fields : {
[LOGIN.ACCOUNT_ID] : account_id,
}
});
const logins = await pool.query('SELECT * FROM logins WHERE account_id=$1 ORDER by login_time DESC ' + ` OFFSET $2 LIMIT $3`, values);
pagination.item_count = login_count;

View file

@ -1,8 +1,8 @@
import * as db from './db.js'
import assert from 'assert'
export function get_pagination(req) {
let page = parseInt(req.query.page);
let order = req.query.order; // TODO
if (!page || page < 1) {
page = 1;
@ -11,10 +11,106 @@ export function get_pagination(req) {
return {
page: page,
items_per_page: 40,
//order: order, // TODO
};
}
export function get_sort_param(param) {
const tokens = param.split("_");
let order = db.SQL_ORDER.ASCENDING;
// default to ascending if malformed
if (tokens[tokens.length-1] == "desc")
order = db.SQL_ORDER.DESCENDING;
else if (tokens[tokens.length-1] == "asc")
order = db.SQL_ORDER.ASCENDING;
return [tokens.slice(0, tokens.length-1).join("_"), order]
}
export function get_sort(req, sort_spec) {
assert(sort_spec && sort_spec.param && sort_spec.default && sort_spec.types,
"Invalid filter specification");
assert(sort_spec.default, "Default sort specification required");
Object.keys(sort_spec.types).forEach((k) => {
assert(typeof k == 'string', "Sort key spec must be a string")
assert(typeof sort_spec.types[k] == 'symbol', "Sort value MUST be a symbolic table column")
});
const default_sort_parsed = get_sort_param(sort_spec.default);
assert(default_sort_parsed[0] in sort_spec.types, "Default sort type MUST be in spec")
const default_sort = { [sort_spec.types[default_sort_parsed[0]]] : default_sort_parsed[1] };
const sort_query = req.query[sort_spec.param];
// return default
if (!sort_query) {
return default_sort;
}
const sort = {}
const types = sort_query.split(",");
for (let i = 0; i < types.length; i++) {
const t = types[i];
const sparsed = get_sort_param(t);
if (!(sparsed[0] in sort_spec.types)) {
console.log("WARNING: unknown sort type " + t);
return default_sort;
}
const column_name = sort_spec.types[sparsed[0]];
sort[column_name] = sparsed[1];
}
return sort;
}
export function get_filter(req, filter_spec) {
assert(filter_spec && filter_spec.param && filter_spec.default && filter_spec.types,
"Invalid filter specification");
const filter_query = req.query[filter_spec.param];
if (!filter_query) {
return {};
}
const types = filter_query.split(",");
// No filtering needed, everything displayed
if (filter_spec.default in types) {
return {}
}
const filter = {
binop : "OR", // TODO: support more complex queries
fields : {},
}
for (let i = 0; i < types.length; i++) {
const t = types[i];
if (!(t in filter_spec.types)) {
console.log("WARNING: unknown filter type " + t);
return {}
}
const constraint = filter_spec.types[t];
Object.getOwnPropertySymbols(constraint).forEach(key => {
assert(Object.keys(constraint) == 0 && typeof key == 'symbol',
"filter constraint keys MUST be symbolic table columns")
filter.fields[key] = constraint[key];
});
}
return filter;
}
export async function fetch_user_middleware(req, res, next, id) {
id = parseInt(id);

View file

@ -11,7 +11,10 @@
let fetching = false;
let pagination = { page: 1 };
export async function refresh() {
export async function refresh(newpage) {
if (newpage !== undefined && typeof newpage == "number")
await list_fetch(newpage)
else
await list_fetch(pagination.page)
}

View file

@ -100,4 +100,4 @@
</p>
{/if}
<ActionModal on:action={refresh} />
<ActionModal on:action={() => refresh()} />

View file

@ -1,18 +1,93 @@
<script>
import axios from 'axios'
import { onMount } from 'svelte'
import AccountLink from '../components/AccountLink'
import PaginatedList from '../components/PaginatedList'
import ActionButtons from '../components/ActionButtons'
import ActionModal from '../components/ActionModal.svelte'
import { formToJSON } from '../util/form.js'
import axios from 'axios'
import moment from 'moment'
export let setURLParam = false;
export let appAlert
export let appAlert;
let userList;
let filter = "";
let sort = ""
function handleSort(event) {
const ele = event.target;
const classes = ele.classList;
const thead = ele.parentElement;
if (!classes.contains("sortable")) {
return;
}
let found, next = "";
let sortstate = {
"both" : "asc",
"asc" : "desc",
"desc" : "asc"
}
const keys = Object.keys(sortstate);
for (let i = 0; i < keys.length; i++) {
const k = keys[i];
if (classes.contains(k)) {
if (!ele.dataset.sort)
break;
found = k;
sort = `sort=${ele.dataset.sort}_${sortstate[k]}&`
ele.classList.remove(k);
ele.classList.add(sortstate[k]);
break;
}
}
// invalid sort
if (!found) return;
// clear other sorts
Array.from(thead.children).forEach((child) =>{
if (child == ele)
return;
if (child.classList.contains("sortable")) {
child.classList.remove("asc")
child.classList.remove("desc")
child.classList.add("both")
}
});
userList.refresh()
}
function changeFilter(event) {
const filters = formToJSON(event.target.form);
if (Object.keys(filters).length === 0) {
filter = "";
userList.refresh()
return;
}
const ufilter = Object.keys(filters).join(",");
const url = new URL(window.location.href);
url.searchParams.set('ufilter', ufilter);
history.replaceState(null, null,
url.pathname + url.search + url.hash)
filter = `filter=${ufilter}&`
// Filter changes go to the first page
userList.refresh(1)
}
async function fetch(page) {
try {
const resp = await axios.get("/api/users?page="+page)
const resp = await axios.get(`/api/users?${filter}${sort}page=${page}`)
appAlert.message("")
return [resp.data.users, resp.data.page];
} catch (e) {
@ -25,14 +100,36 @@
<PaginatedList {setURLParam} bind:this={userList} bind:fetch={fetch} let:data={users} let:pagination={pagination}>
<div slot="header">
<p>{pagination.item_count.toLocaleString()} users in the database</p>
<div class="dropdown float-right">
<button class="btn btn-secondary dropdown-toggle" type="button" id="dropdownMenuButton" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
Filter
</button>
<div class="dropdown-menu dropdown-menu-right" aria-labelledby="dropdownMenuButton">
<form on:change={changeFilter} class="px-4 py-3">
<div class="form-check">
<input type="checkbox" class="form-check-input" name="gm" id="userFilterGM">
<label class="form-check-label" for="userFilterGM">
GM
</label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" name="banned" id="userFilterBanned">
<label class="form-check-label" for="userFilterBanned">
Banned
</label>
</div>
</form>
</div>
</div>
</div>
<table slot="body" class="table table-sm table-dark table-responsive-md table-striped table-hover">
<thead class="thead-light">
<th>ID</th>
<th>Username</th>
<th>User Created</th>
<th>Last Login</th>
<thead on:click={handleSort} class="thead-light">
<th data-sort="id" class="sortable both">ID</th>
<th data-sort="username" class="sortable both">Username</th>
<th data-sort="created" class="sortable both">User Created</th>
<th data-sort="last_login" class="sortable both">Last Login</th>
<th>Actions</th>
</thead>
<tbody>
@ -55,4 +152,4 @@
</table>
</PaginatedList>
<ActionModal on:action={userList.refresh} />
<ActionModal on:action={() => userList.refresh()} />

View file

@ -79,3 +79,27 @@ button:focus {
.form-control-plaintext {
color: white;
}
table thead {
// Stolen from https://github.com/wenzhixin/bootstrap-table/blob/develop/src/themes/_theme.scss#L103
.sortable {
cursor: pointer;
background-position: right;
background-repeat: no-repeat;
padding-right: 30px !important;
}
.both {
// This was too light...
//background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABMAAAATCAQAAADYWf5HAAAAkElEQVQoz7X QMQ5AQBCF4dWQSJxC5wwax1Cq1e7BAdxD5SL+Tq/QCM1oNiJidwox0355mXnG/DrEtIQ6azioNZQxI0ykPhTQIwhCR+BmBYtlK7kLJYwWCcJA9M4qdrZrd8pPjZWPtOqdRQy320YSV17OatFC4euts6z39GYMKRPCTKY9UnPQ6P+GtMRfGtPnBCiqhAeJPmkqAAAAAElFTkSuQmCC');
background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABMAAAATCAQAAADYWf5HAAAAAmJLR0QA/4ePzL8AAAAJcEhZcwAACxMAAAsTAQCanBgAAAAHdElNRQfjDB8SGB+BP4v2AAAAzklEQVQoz2NgoCmYyFUyYSIbuigTusCr7KtZb1IJKOvSupX3j/VOQbcyHmUTGZ8Vf5JhYHin8jJvIiNOZV8dHgRCWLcSv1njUNbHe7/2uyCE/ZP3Qc1EdqzKPoY8tWb5AYOPHT8EkRxMSA5tjDy5gPEfjPefyTSjcT4WSwXWye7/wwGD0kf512B1W/5PhRb2zxA253vF5qLPOAKE66ga1BqF9dwHcIZb/n/xSUJ3GBj4nkj15v/H66fabM9f1aXooizoAiKztVXFptI2FQIAo85H84//KTgAAAAASUVORK5CYII=');
}
.asc {
background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABMAAAATCAYAAAByUDbMAAAAZ0lEQVQ4y2NgGLKgquEuFxBPAGI2ahhWCsS/gDibUoO0gPgxEP8H4ttArEyuQYxAPBdqEAxPBImTY5gjEL9DM+wTENuQahAvEO9DMwiGdwAxOymGJQLxTyD+jgWDxCMZRsEoGAVoAADeemwtPcZI2wAAAABJRU5ErkJggg==');
}
.desc {
background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABMAAAATCAYAAAByUDbMAAAAZUlEQVQ4y2NgGAWjYBSggaqGu5FA/BOIv2PBIPFEUgxjB+IdQPwfC94HxLykus4GiD+hGfQOiB3J8SojEE9EM2wuSJzcsFMG4ttQgx4DsRalkZENxL+AuJQaMcsGxBOAmGvopk8AVz1sLZgg0bsAAAAASUVORK5CYII= ');
}
}