Admin: Added filter / search panel to nodes list.
This commit is contained in:
parent
ac7f8c98b0
commit
f95829adc6
13 changed files with 568 additions and 45 deletions
|
@ -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>
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
382
frontend/src/components/nodes/NodesFilterPanel.vue
Normal file
382
frontend/src/components/nodes/NodesFilterPanel.vue
Normal 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>
|
|
@ -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>
|
||||
|
|
|
@ -2,12 +2,6 @@
|
|||
import { useConfigStore } from "@/stores/config";
|
||||
|
||||
const config = useConfigStore();
|
||||
|
||||
function refresh(): void {
|
||||
config.refresh();
|
||||
}
|
||||
|
||||
refresh();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
|
@ -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");
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue