Admin: Added filter / search panel to nodes list.

This commit is contained in:
baldo 2022-06-17 15:30:29 +02:00
parent ac7f8c98b0
commit f95829adc6
13 changed files with 568 additions and 45 deletions

View file

@ -32,5 +32,43 @@ main {
a {
color: $link-color;
text-decoration: none;
&:hover {
color: $link-hover-color;
}
&:focus {
outline: 0.1em solid $link-hover-color;
}
}
button {
padding: $button-padding;
border-radius: $button-border-radius;
border-width: $button-border-width;
border-style: $button-border-style;
cursor: pointer;
@each $variant, $color in $variant-colors {
&.#{$variant} {
background-color: map-get($variant-text-colors, $variant);
border-color: $color;
color: $color;
&:hover, &:active {
background-color: $color;
border-color: $color;
color: map-get($variant-text-colors, $variant);
}
&:focus {
background-color: $color;
border-color: $page-background-color;
color: map-get($variant-text-colors, $variant);
outline: 0.1em solid $color;
}
}
}
}
</style>

View file

@ -17,7 +17,6 @@ const props = defineProps({
});
const firstItem = computed(() => {
console.log(props.totalItems, props.page, props.itemsPerPage);
return Math.min(props.totalItems, (props.page - 1) * props.itemsPerPage + 1)
});
const lastItem = computed(() => Math.min(props.totalItems, firstItem.value + props.itemsPerPage - 1));

View file

@ -17,7 +17,9 @@ const linkTarget = computed(() => {
if (props.filter) {
return {
path: props.link,
query: props.filter,
query: {
filter: JSON.stringify(props.filter),
},
}
} else {
return props.link;
@ -47,11 +49,19 @@ const linkTarget = computed(() => {
padding: $statistics-card-padding;
border-radius: $statistics-card-border-radius;
border-width: 0.2em;
border-style: solid;
@each $variant, $color in $variant-colors {
&.statistics-card-#{$variant} {
background-color: $color;
color: map-get($variant-text-colors, $variant);
border-color: $color;
&:focus {
border-color: $page-background-color;
outline: 0.2em solid $color;
}
}
}

View file

@ -0,0 +1,382 @@
<script setup lang="ts">
import {
isMap,
isNodesFilter,
isString,
MonitoringState,
NODES_FILTER_FIELDS,
type NodesFilter,
OnlineState,
type UnixTimestampMilliseconds,
} from "@/types";
import {computed, nextTick, onMounted, ref, watch} from "vue";
import {useConfigStore} from "@/stores/config";
interface Props {
filter: NodesFilter;
}
const SEARCH_THROTTLE_DELAY_MS = 500;
const FILTER_LABELS: Record<string, string | Map<any, any>> = {
hasKey: new Map([
[true, "Mit VPN-Schlüssel"],
[false, "Ohne VPN-Schlüssel"],
]),
hasCoords: new Map([
[true, "Mit Koordinaten"],
[false, "Ohne Koordinaten"],
]),
monitoringState: new Map([
[MonitoringState.ACTIVE, "Monitoring: aktiv"],
[MonitoringState.PENDING, "Monitoring: Bestätigung ausstehend"],
[MonitoringState.DISABLED, "Monitoring: nicht aktiv"],
]),
site: "Site",
domain: "Domäne",
onlineState: new Map([
[OnlineState.ONLINE, "online"],
[OnlineState.OFFLINE, "offline"],
]),
}
const emit = defineEmits<{
(e: "updateFilter", filter: NodesFilter, searchTerm?: string): void,
}>();
const props = defineProps<Props>();
const input = ref();
const hasFocus = ref(false);
const suggestedFiltersExpanded = ref(false);
const config = useConfigStore();
type Filter = {
field: string,
value: any,
};
const selectedFilters = ref<Filter[]>([]);
function selectedFilterIndex(filter: Filter): number {
for (let i = 0; i < selectedFilters.value.length; i += 1) {
const selectedFilter = selectedFilters.value[i];
if (selectedFilter.field === filter.field && selectedFilter.value === filter.value) {
return i;
}
}
return -1;
}
function selectedFilterIndexForField(field: string): number {
for (let i = 0; i < selectedFilters.value.length; i += 1) {
const selectedFilter = selectedFilters.value[i];
if (selectedFilter.field === field) {
return i;
}
}
return -1;
}
const suggestedFilters = computed<Filter[][]>(() => {
const cfg = config.getConfig;
const sites = cfg?.community.sites || [];
const domains = cfg?.community.domains || [];
const filterGroups: Filter[][] = [];
for (const field of Object.keys(NODES_FILTER_FIELDS)) {
const filterGroup: Filter[] = []
function pushFilter(filter: Filter): void {
if (selectedFilterIndex(filter) < 0) {
filterGroup.push(filter);
}
}
switch (field) {
case "site":
for (const site of sites) {
pushFilter({
field: "site",
value: site,
});
}
break;
case "domain":
for (const domain of domains) {
pushFilter({
field: "domain",
value: domain,
});
}
break;
default:
const labels = FILTER_LABELS[field];
if (!isMap(labels)) {
throw new Error(`Missing case for field ${field}.`);
}
for (const value of labels.keys()) {
pushFilter({
field,
value,
});
}
}
filterGroups.push(filterGroup);
}
return filterGroups;
});
function addSelectedFilter(filter: Filter): void {
const i = selectedFilterIndexForField(filter.field);
if (i >= 0) {
selectedFilters.value.splice(selectedFilterIndex(filter), 1);
}
selectedFilters.value.push(filter);
doSearch();
}
function removeSelectedFilter(filter: Filter): void {
selectedFilters.value.splice(selectedFilterIndex(filter), 1);
doSearch();
}
function updateSelectedFilters() {
const filter = props.filter as Record<string, any>;
selectedFilters.value = [];
for (const field of Object.keys(NODES_FILTER_FIELDS)) {
if (filter.hasOwnProperty(field)) {
addSelectedFilter({
field,
value: filter[field],
});
}
}
}
watch(props, updateSelectedFilters);
onMounted(updateSelectedFilters);
function renderFilter(filter: Filter): string {
if (!FILTER_LABELS.hasOwnProperty(filter.field)) {
throw new Error(`Filter has no translation: ${filter.field}`);
}
const label = FILTER_LABELS[filter.field];
if (isString(label)) {
return `${label}: ${filter.value}`;
}
if (!label.has(filter.value)) {
throw new Error(`Filter ${filter.field} has no translation for value: ${filter.value}(${typeof filter.value})`);
}
return label.get(filter.value);
}
function setFocus(focus: boolean): void {
hasFocus.value = focus;
}
function focusInput(): void {
nextTick(() => input.value.focus());
}
function showSuggestedFilters(expanded: boolean): void {
suggestedFiltersExpanded.value = expanded;
}
function buildNodesFilter(): NodesFilter {
const nodesFilter: Record<string, any> = {};
for (const filter of selectedFilters.value) {
nodesFilter[filter.field] = filter.value;
}
if (!isNodesFilter(nodesFilter)) {
throw new Error(`Invalid nodes filter: ${JSON.stringify(nodesFilter)}`);
}
return nodesFilter;
}
let lastSearchTimestamp: UnixTimestampMilliseconds = 0;
let searchTimeout: NodeJS.Timeout | undefined = undefined;
let lastSearchTerm = "";
function doSearch(): void {
const nodesFilter = buildNodesFilter();
lastSearchTerm = input.value.value;
let searchTerm: string | undefined = lastSearchTerm.trim();
if (!searchTerm) {
searchTerm = undefined;
}
emit("updateFilter", nodesFilter, searchTerm);
}
function doThrottledSearch(): void {
if (lastSearchTerm === input.value.value) {
return
}
const now: UnixTimestampMilliseconds = Date.now();
if (now - SEARCH_THROTTLE_DELAY_MS >= lastSearchTimestamp) {
lastSearchTimestamp = now;
doSearch();
} else if (!searchTimeout) {
searchTimeout = setTimeout(() => {
searchTimeout = undefined
doThrottledSearch();
}, SEARCH_THROTTLE_DELAY_MS);
}
}
</script>
<template>
<div :class="{ 'nodes-filter-panel': true, 'focus': hasFocus}" @click="focusInput">
<div class="nodes-filter-input">
<div class="selected-filters">
<span
v-for="filter in selectedFilters"
class="selected-filter"
@click="removeSelectedFilter(filter)"
:title="renderFilter(filter)">
{{ renderFilter(filter) }}
<i
class="fa fa-times remove-filter"
aria-hidden="true"
title="Filter entfernen"/>
</span>
</div>
<input
ref="input"
@focus="setFocus(true)"
@blur="setFocus(false)"
@keyup="doThrottledSearch()"
maxlength="64"
type="search"
placeholder="Knoten durchsuchen..."/>
<i class="fa fa-search search" @click="doSearch()"/>
</div>
<div class="suggested-filters" v-if="suggestedFiltersExpanded">
<div class="suggested-filter-group" v-for="filterGroup in suggestedFilters">
<span
class="suggested-filter"
v-for="filter in filterGroup"
@click="addSelectedFilter(filter)"
:title="renderFilter(filter)">
{{ renderFilter(filter) }}
<i
class="fa fa-plus add-filter"
aria-hidden="true"
title="Filter hinzufügen"/>
</span>
</div>
</div>
</div>
<a
v-if="suggestedFiltersExpanded"
class="toggle-suggested-filters"
href="javascript:"
@click="showSuggestedFilters(false)">
Erweiterte Suche ausblenden
</a>
<a
v-if="!suggestedFiltersExpanded"
class="toggle-suggested-filters"
href="javascript:"
@click="showSuggestedFilters(true)">
Erweiterte Suche einblenden
</a>
</template>
<style lang="scss" scoped>
@import "../../scss/variables";
.nodes-filter-panel {
position: relative;
background-color: $input-background-color;
border-radius: $input-border-radius;
border: $input-border;
&.focus {
outline: $input-focus-outline;
}
.nodes-filter-input {
display: flex;
margin: 0 0.25em;
}
.selected-filters {
margin: 0.3em 0.25em 0.3em 0;
max-width: 60%;
}
.selected-filter,
.suggested-filter {
cursor: pointer;
margin: 0.1em 0.25em 0.1em 0;
padding: 0.25em 0.5em;
border-radius: 0.75em;
font-size: 0.75em;
color: $nodes-filter-panel-pill-text-color;
background-color: $nodes-filter-panel-pill-background-color;
font-weight: $nodes-filter-panel-pill-font-weight;
user-select: none;
white-space: nowrap;
.add-filter, .remove-filter {
margin-left: 0.25em;
}
}
.selected-filters, .suggested-filter-group {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
overflow: hidden;
span {
display: block;
}
}
.suggested-filters {
border-top: 0.1em solid $page-background-color;
}
.suggested-filter-group {
margin: 0.5em 0.25em;
}
.search {
margin: 0.5em 0.25em 0 0;
color: $page-background-color;
cursor: pointer;
}
input {
margin: 0.75em 0.25em 0.6em 0;
padding: 0;
border: none;
flex-grow: 1;
height: 1em;
outline: none;
background-color: $input-background-color;
color: $input-text-color;
&::placeholder {
opacity: $input-placeholder-opacity;
color: $input-placeholder-color;
}
}
}
.toggle-suggested-filters {
display: block;
margin: 0.5em;
}
</style>

View file

@ -4,13 +4,6 @@ import {useVersionStore} from "@/stores/version";
const config = useConfigStore();
const version = useVersionStore();
function refresh(): void {
config.refresh();
version.refresh();
}
refresh();
</script>
<template>

View file

@ -2,12 +2,6 @@
import { useConfigStore } from "@/stores/config";
const config = useConfigStore();
function refresh(): void {
config.refresh();
}
refresh();
</script>
<template>

View file

@ -3,10 +3,15 @@ import { createPinia } from "pinia";
import App from "./App.vue";
import router from "./router";
import {useConfigStore} from "@/stores/config";
import {useVersionStore} from "@/stores/version";
const app = createApp(App);
app.use(createPinia());
app.use(router);
useConfigStore().refresh().catch(err => console.error(err));
useVersionStore().refresh().catch(err => console.error(err));
app.mount("#app");

View file

@ -21,9 +21,25 @@ const router = createRouter({
path: "/admin/nodes",
name: "admin-nodes",
component: AdminNodesView,
props: route => ({
filter: isNodesFilter(route.query) ? route.query : {}
})
props: route => {
let filter: any;
if (route.query.hasOwnProperty("filter")) {
try {
filter = JSON.parse(route.query.filter as string);
} catch (e) {
console.warn(e);
filter = {};
}
} else {
filter = {};
}
const searchTerm = route.query.q ? route.query.q as string : undefined;
return {
filter: isNodesFilter(filter) ? filter : {},
searchTerm,
}
}
},
],
});

View file

@ -1,8 +1,8 @@
// Grays
$black: #000000;
$gray-darkest: #222222;
$gray-darker: #333333;
$gray-dark: #444444;
$gray-darkest: #1c1c1c;
$gray-darker: #2c2c2c;
$gray-dark: #333333;
$gray: #666666;
$gray-light: #d6d6d6;
$gray-lighter: #ededed;
@ -14,7 +14,7 @@ $variant-colors: (
success: #4ba74b,
warning: #fdbc41,
danger: #ef5652,
info: #009ee0,
info: #0097c4,
);
$variant-text-colors: (
// primary: do not use, contrast too low
@ -37,9 +37,24 @@ $page-padding: 0.5em 0.5em 4.5em 0.5em;
// Links
$link-color: $variant-color-warning;
$link-hover-color: $variant-color-warning;
// Inputs
$input-background-color: $gray-lighter;
$input-text-color: $gray-darkest;
$input-placeholder-color: $gray-darkest;
$input-placeholder-opacity: 0.8;
$input-border: 0.1em solid $page-background-color;
$input-border-radius: 0.5em;
$input-focus-outline: 0.1em solid $variant-color-info;
$button-padding: 0.25em 0.5em;
$button-border-radius: $input-border-radius;
$button-border-width: 0.1em;
$button-border-style: solid;
// Navigation
$nav-bar-background-color: $gray-darker;
$nav-bar-background-color: $gray-dark;
$nav-link-color: $page-text-color;
$nav-link-spacing: 1.5em;
@ -57,3 +72,9 @@ $statistics-card-border-radius: 0.5em;
$statistics-card-icon-size: 4em;
$statistics-card-icon-gap: 0.15em;
$statistics-card-value-font-size: 2.5em;
// Nodes filter panel
$nodes-filter-panel-pill-variant: success;
$nodes-filter-panel-pill-text-color: map-get($variant-text-colors, $nodes-filter-panel-pill-variant);
$nodes-filter-panel-pill-background-color: map-get($variant-colors, $nodes-filter-panel-pill-variant);
$nodes-filter-panel-pill-font-weight: bold;

View file

@ -37,13 +37,19 @@ export const useNodesStore = defineStore({
},
},
actions: {
async refresh(page: number, nodesPerPage: number, filter: NodesFilter): Promise<void> {
async refresh(page: number, nodesPerPage: number, filter: NodesFilter, searchTerm?: string): Promise<void> {
const query: Record<string, any> = {
...filter,
};
if (searchTerm) {
query.q = searchTerm;
}
const result = await internalApi.getPagedList<EnhancedNode>(
"nodes",
isEnhancedNode,
page,
nodesPerPage,
filter,
query,
);
this.nodes = result.entries;
this.totalNodes = result.total;

View file

@ -1,6 +1,6 @@
import { defineStore } from "pinia";
import { isObject, isVersion, type Version } from "@/types";
import { api } from "@/utils/Api";
import {defineStore} from "pinia";
import {isObject, isVersion, type Version} from "@/types";
import {api} from "@/utils/Api";
interface VersionResponse {
version: Version;

View file

@ -1,17 +1,22 @@
<script setup lang="ts">
import {useNodesStore} from "@/stores/nodes";
import {onMounted, ref} from "vue";
import {onMounted, ref, watch} from "vue";
import type {EnhancedNode, MAC, NodesFilter} from "@/types";
import Pager from "@/components/Pager.vue";
import LoadingContainer from "@/components/LoadingContainer.vue";
import NodesFilterPanel from "@/components/nodes/NodesFilterPanel.vue";
import router from "@/router";
const NODE_PER_PAGE = 50;
interface Props {
filter: NodesFilter;
searchTerm?: string;
}
const props = defineProps<Props>();
const currentFilter = ref<NodesFilter>({});
const currentSearchTerm = ref<string | undefined>(undefined);
type NodeRedactField = "nickname" | "email" | "token";
type NodeRedactFieldsMap = Partial<Record<NodeRedactField, boolean>>;
@ -27,7 +32,7 @@ async function refresh(page: number): Promise<void> {
loading.value = true;
redactAllFields(true);
try {
await nodes.refresh(page, NODE_PER_PAGE, props.filter);
await nodes.refresh(page, NODE_PER_PAGE, currentFilter.value, currentSearchTerm.value);
} finally {
loading.value = false;
}
@ -56,25 +61,37 @@ function setRedactField(node: EnhancedNode, field: NodeRedactField, value: boole
redactFieldsMap[field] = value;
}
onMounted(async () => await refresh(1));
async function updateFilter(filter: NodesFilter, searchTerm?: string): Promise<void> {
const filterStr = Object.keys(filter).length > 0 ? JSON.stringify(filter) : undefined;
await router.push({
path: '/admin/nodes',
query: {
q: searchTerm,
filter: filterStr
}
});
currentFilter.value = filter;
currentSearchTerm.value = searchTerm;
await refresh(1);
}
async function refreshFromProps(): Promise<void> {
const needsRefresh = currentFilter.value !== props.filter || currentSearchTerm.value !== props.searchTerm;
currentFilter.value = props.filter;
currentSearchTerm.value = props.searchTerm;
if (needsRefresh) {
await refresh(1);
}
}
onMounted(refreshFromProps);
watch(props, refreshFromProps);
</script>
<template>
<h2>Knoten</h2>
<div>
<span>Gesamt: {{ nodes.getTotalNodes }}</span>
<button
v-if="redactFieldsByDefault"
@click="redactAllFields(false)">
Sensible Daten einblenden
</button>
<button
v-if="!redactFieldsByDefault"
@click="redactAllFields(true)">
Sensible Daten ausblenden
</button>
</div>
<NodesFilterPanel :filter="props.filter" @update-filter="updateFilter"/>
<Pager
:page="nodes.getPage"
@ -82,6 +99,21 @@ onMounted(async () => await refresh(1));
:totalItems="nodes.getTotalNodes"
@changePage="refresh"/>
<div class="actions">
<button
v-if="redactFieldsByDefault"
class="warning"
@click="redactAllFields(false)">
Sensible Daten einblenden
</button>
<button
v-if="!redactFieldsByDefault"
class="success"
@click="redactAllFields(true)">
Sensible Daten ausblenden
</button>
</div>
<LoadingContainer :loading="loading">
<table>
<thead>
@ -208,9 +240,17 @@ onMounted(async () => await refresh(1));
<style lang="scss" scoped>
@import "../scss/variables";
.actions {
margin: 0 0 0.5em 0;
}
table {
border-collapse: collapse;
tbody tr:hover {
background-color: $gray-darker;
}
th, td {
padding: 0.5em 0.25em;
}

View file

@ -19,6 +19,10 @@ export function isArray<T>(arg: unknown, isT: TypeGuard<T>): arg is Array<T> {
return true;
}
export function isMap(arg: unknown): arg is Map<any, any> {
return arg instanceof Map;
}
export function isString(arg: unknown): arg is string {
return typeof arg === "string"
}
@ -333,8 +337,20 @@ export interface NodesFilter {
hasKey?: boolean;
hasCoords?: boolean;
monitoringState?: MonitoringState;
site?: Site;
domain?: Domain;
onlineState?: OnlineState;
}
export const NODES_FILTER_FIELDS = {
hasKey: Boolean,
hasCoords: Boolean,
monitoringState: MonitoringState,
site: String,
domain: String,
onlineState: OnlineState,
};
export function isNodesFilter(arg: unknown): arg is NodesFilter {
if (!isObject(arg)) {
return false;
@ -343,6 +359,9 @@ export function isNodesFilter(arg: unknown): arg is NodesFilter {
return (
isOptional(filter.hasKey, isBoolean) &&
isOptional(filter.hasCoords, isBoolean) &&
isOptional(filter.monitoringState, isMonitoringState)
isOptional(filter.monitoringState, isMonitoringState) &&
isOptional(filter.site, isSite) &&
isOptional(filter.domain, isDomain) &&
isOptional(filter.onlineState, isOnlineState)
);
}