admin page improvements

This commit is contained in:
Kieran 2022-06-13 21:26:42 +01:00
parent 7ea99de160
commit 1907e3261b
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
9 changed files with 106 additions and 41 deletions

View File

@ -13,14 +13,16 @@ public class AdminController : Controller
private readonly IFileMetadataStore _fileMetadata; private readonly IFileMetadataStore _fileMetadata;
private readonly IFileInfoManager _fileInfo; private readonly IFileInfoManager _fileInfo;
private readonly IUserStore _userStore; private readonly IUserStore _userStore;
private readonly IUserUploadsStore _userUploads;
public AdminController(IFileStore fileStore, IUserStore userStore, IFileInfoManager fileInfo, public AdminController(IFileStore fileStore, IUserStore userStore, IFileInfoManager fileInfo,
IFileMetadataStore fileMetadata) IFileMetadataStore fileMetadata, IUserUploadsStore userUploads)
{ {
_fileStore = fileStore; _fileStore = fileStore;
_userStore = userStore; _userStore = userStore;
_fileInfo = fileInfo; _fileInfo = fileInfo;
_fileMetadata = fileMetadata; _fileMetadata = fileMetadata;
_userUploads = userUploads;
} }
/// <summary> /// <summary>
@ -63,9 +65,23 @@ public class AdminController : Controller
/// <returns></returns> /// <returns></returns>
[HttpPost] [HttpPost]
[Route("user")] [Route("user")]
public async Task<RenderedResults<PrivateVoidUser>> ListUsers([FromBody] PagedRequest request) public async Task<RenderedResults<AdminListedUser>> ListUsers([FromBody] PagedRequest request)
{ {
var result = await _userStore.ListUsers(request); var result = await _userStore.ListUsers(request);
return await result.GetResults();
var ret = await result.Results.SelectAwait(async a =>
{
var uploads = await _userUploads.ListFiles(a.Id, new(0, int.MaxValue));
return new AdminListedUser(a, uploads.TotalResults);
}).ToListAsync();
return new()
{
PageSize = request.PageSize,
Page = request.Page,
TotalResults = result.TotalResults,
Results = ret
};
} }
public record AdminListedUser(PrivateVoidUser User, int Uploads);
} }

View File

@ -4,9 +4,31 @@
margin-right: auto; margin-right: auto;
} }
.admin h2 {
background-color: #222;
padding: 10px;
}
.admin table { .admin table {
width: 100%; width: 100%;
word-break: keep-all; word-break: keep-all;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} border-collapse: collapse;
}
.admin table th {
background-color: #222;
text-align: start;
}
.admin table tr:nth-child(2n) {
background-color: #111;
}
.admin .btn {
padding: 5px 8px;
border-radius: 3px;
font-size: small;
margin: 2px;
}

View File

@ -4,6 +4,7 @@ import {FileList} from "../FileList";
import {UserList} from "./UserList"; import {UserList} from "./UserList";
import {Navigate} from "react-router-dom"; import {Navigate} from "react-router-dom";
import {useApi} from "../Api"; import {useApi} from "../Api";
import {VoidButton} from "../VoidButton";
export function Admin() { export function Admin() {
const auth = useSelector((state) => state.login.jwt); const auth = useSelector((state) => state.login.jwt);
@ -11,7 +12,6 @@ export function Admin() {
async function deleteFile(e, id) { async function deleteFile(e, id) {
e.target.disabled = true;
if (window.confirm(`Are you sure you want to delete: ${id}?`)) { if (window.confirm(`Are you sure you want to delete: ${id}?`)) {
let req = await AdminApi.deleteFile(id); let req = await AdminApi.deleteFile(id);
if (req.ok) { if (req.ok) {
@ -20,21 +20,20 @@ export function Admin() {
alert("Failed to delete file!"); alert("Failed to delete file!");
} }
} }
e.target.disabled = false;
} }
if (!auth) { if (!auth) {
return <Navigate to="/login"/>; return <Navigate to="/login"/>;
} else { } else {
return ( return (
<div className="admin"> <div className="admin">
<h4>Users</h4> <h2>Users</h2>
<UserList/> <UserList/>
<h4>Files</h4> <h2>Files</h2>
<FileList loadPage={AdminApi.fileList} actions={(i) => { <FileList loadPage={AdminApi.fileList} actions={(i) => {
return <td> return <td>
<button onClick={(e) => deleteFile(e, i.id)}>Delete</button> <VoidButton onClick={(e) => deleteFile(e, i.id)}>Delete</VoidButton>
</td> </td>
}}/> }}/>
</div> </div>

View File

@ -5,6 +5,7 @@ import {useApi} from "../Api";
import {logout} from "../LoginState"; import {logout} from "../LoginState";
import {PageSelector} from "../PageSelector"; import {PageSelector} from "../PageSelector";
import moment from "moment"; import moment from "moment";
import {VoidButton} from "../VoidButton";
export function UserList() { export function UserList() {
const {AdminApi} = useApi(); const {AdminApi} = useApi();
@ -19,7 +20,7 @@ export function UserList() {
page: page, page: page,
pageSize, pageSize,
sortBy: PagedSortBy.Date, sortBy: PagedSortBy.Date,
sortOrder: PageSortOrder.Asc sortOrder: PageSortOrder.Dsc
}; };
let req = await AdminApi.userList(pageReq); let req = await AdminApi.userList(pageReq);
if (req.ok) { if (req.ok) {
@ -31,17 +32,17 @@ export function UserList() {
} }
} }
function renderUser(u) { function renderUser(obj) {
const user = obj.user;
return ( return (
<tr key={u.id}> <tr key={user.id}>
<td><a href={`/u/${u.id}`}>{u.id.substring(0, 4)}..</a></td> <td><a href={`/u/${user.id}`}>{user.displayName}</a></td>
<td>{moment(u.created).fromNow()}</td> <td>{moment(user.created).fromNow()}</td>
<td>{moment(u.lastLogin).fromNow()}</td> <td>{moment(user.lastLogin).fromNow()}</td>
<td>0</td> <td>{obj.uploads}</td>
<td>{u.roles.join(", ")}</td>
<td> <td>
<button>Delete</button> <VoidButton>Delete</VoidButton>
<button>SetRoles</button> <VoidButton>SetRoles</VoidButton>
</td> </td>
</tr> </tr>
); );
@ -59,12 +60,11 @@ export function UserList() {
<table> <table>
<thead> <thead>
<tr> <tr>
<td>Id</td> <th>Name</th>
<td>Created</td> <th>Created</th>
<td>Last Login</td> <th>Last Login</th>
<td>Files</td> <th>Files</th>
<td>Roles</td> <th>Actions</th>
<td>Actions</td>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -73,8 +73,11 @@ export function UserList() {
<tbody> <tbody>
<tr> <tr>
<td> <td>
{users ? <PageSelector onSelectPage={(x) => setPage(x)} page={page} total={users.totalResults} {users ? <PageSelector
pageSize={pageSize}/> : null} onSelectPage={(x) => setPage(x)}
page={page}
total={users.totalResults}
pageSize={pageSize}/> : null}
</td> </td>
</tr> </tr>
</tbody> </tbody>

View File

@ -3,4 +3,14 @@ table.file-list {
word-break: keep-all; word-break: keep-all;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
border-collapse: collapse;
}
table.file-list tr:nth-child(2n) {
background-color: #111;
}
table.file-list th {
background-color: #222;
text-align: start;
} }

View File

@ -14,7 +14,7 @@ export function FileList(props) {
const dispatch = useDispatch(); const dispatch = useDispatch();
const [files, setFiles] = useState(); const [files, setFiles] = useState();
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const pageSize = 10; const pageSize = 20;
const [accessDenied, setAccessDenied] = useState(false); const [accessDenied, setAccessDenied] = useState(false);
async function loadFileList() { async function loadFileList() {
@ -62,16 +62,18 @@ export function FileList(props) {
<table className="file-list"> <table className="file-list">
<thead> <thead>
<tr> <tr>
<td>Id</td> <th>Id</th>
<td>Name</td> <th>Name</th>
<td>Uploaded</td> <th>Uploaded</th>
<td>Size</td> <th>Size</th>
<td>Egress</td> <th>Egress</th>
{actions ? <td>Actions</td> : null} {actions ? <th>Actions</th> : null}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{files ? files.results.map(a => renderItem(a)) : <tr><td colSpan={99}>No files</td></tr>} {files ? files.results.map(a => renderItem(a)) : <tr>
<td colSpan={99}>No files</td>
</tr>}
</tbody> </tbody>
<tbody> <tbody>
<tr> <tr>

View File

@ -86,9 +86,10 @@ export function FileUpload(props) {
* @param segment {ArrayBuffer} * @param segment {ArrayBuffer}
* @param id {string} * @param id {string}
* @param editSecret {string?} * @param editSecret {string?}
* @param fullDigest {string?} Full file hash
* @returns {Promise<any>} * @returns {Promise<any>}
*/ */
async function xhrSegment(segment, id, editSecret) { async function xhrSegment(segment, id, editSecret, fullDigest) {
setUState(UploadState.Hashing); setUState(UploadState.Hashing);
const digest = await crypto.subtle.digest(DigestAlgo, segment); const digest = await crypto.subtle.digest(DigestAlgo, segment);
setUState(UploadState.Uploading); setUState(UploadState.Uploading);
@ -116,6 +117,7 @@ export function FileUpload(props) {
req.setRequestHeader("V-Content-Type", props.file.type); req.setRequestHeader("V-Content-Type", props.file.type);
req.setRequestHeader("V-Filename", props.file.name); req.setRequestHeader("V-Filename", props.file.name);
req.setRequestHeader("V-Digest", buf2hex(digest)); req.setRequestHeader("V-Digest", buf2hex(digest));
req.setRequestHeader("V-Full-Digest", fullDigest);
if (auth) { if (auth) {
req.setRequestHeader("Authorization", `Bearer ${auth}`); req.setRequestHeader("Authorization", `Bearer ${auth}`);
} }
@ -134,12 +136,14 @@ export function FileUpload(props) {
// upload file in segments of 50MB // upload file in segments of 50MB
const UploadSize = 50_000_000; const UploadSize = 50_000_000;
let digest = await crypto.subtle.digest(DigestAlgo, await props.file.arrayBuffer());
let xhr = null; let xhr = null;
const segments = props.file.size / UploadSize; const segments = props.file.size / UploadSize;
for (let s = 0; s < segments; s++) { for (let s = 0; s < segments; s++) {
let offset = s * UploadSize; let offset = s * UploadSize;
let slice = props.file.slice(offset, offset + UploadSize, props.file.type); let slice = props.file.slice(offset, offset + UploadSize, props.file.type);
xhr = await xhrSegment(await slice.arrayBuffer(), xhr?.file?.id, xhr?.file?.metadata?.editSecret); let segment = await slice.arrayBuffer();
xhr = await xhrSegment(segment, xhr?.file?.id, xhr?.file?.metadata?.editSecret, buf2hex(digest));
if (!xhr.ok) { if (!xhr.ok) {
break; break;
} }

View File

@ -2,6 +2,7 @@
display: grid; display: grid;
grid-auto-flow: column; grid-auto-flow: column;
width: min-content; width: min-content;
margin-top: 10px;
} }
.page-buttons > div { .page-buttons > div {
@ -11,6 +12,11 @@
cursor: pointer; cursor: pointer;
} }
.page-buttons > div.active {
background-color: #333;
font-weight: bold;
}
.page-buttons > div:first-child { .page-buttons > div:first-child {
border-top-left-radius: 3px; border-top-left-radius: 3px;
border-bottom-left-radius: 3px; border-bottom-left-radius: 3px;
@ -24,4 +30,5 @@
.page-buttons > small { .page-buttons > small {
line-height: 32px; line-height: 32px;
margin-left: 10px; margin-left: 10px;
} }

View File

@ -6,7 +6,7 @@ export function PageSelector(props) {
const page = props.page; const page = props.page;
const onSelectPage = props.onSelectPage; const onSelectPage = props.onSelectPage;
const options = { const options = {
showPages: 2, showPages: 3,
...(props.options || {}) ...(props.options || {})
}; };
@ -17,7 +17,9 @@ export function PageSelector(props) {
let buttons = []; let buttons = [];
for (let x = first; x <= last; x++) { for (let x = first; x <= last; x++) {
buttons.push(<div onClick={(e) => onSelectPage(x)} key={x}>{x+1}</div>); buttons.push(<div onClick={(e) => onSelectPage(x)} key={x} className={page === x ? "active" : null}>
{x + 1}
</div>);
} }
return ( return (