Admin: Added filter / search panel to nodes list.
This commit is contained in:
parent
ac7f8c98b0
commit
f95829adc6
|
@ -32,5 +32,43 @@ main {
|
||||||
a {
|
a {
|
||||||
color: $link-color;
|
color: $link-color;
|
||||||
text-decoration: none;
|
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>
|
</style>
|
||||||
|
|
|
@ -17,7 +17,6 @@ const props = defineProps({
|
||||||
});
|
});
|
||||||
|
|
||||||
const firstItem = computed(() => {
|
const firstItem = computed(() => {
|
||||||
console.log(props.totalItems, props.page, props.itemsPerPage);
|
|
||||||
return Math.min(props.totalItems, (props.page - 1) * props.itemsPerPage + 1)
|
return Math.min(props.totalItems, (props.page - 1) * props.itemsPerPage + 1)
|
||||||
});
|
});
|
||||||
const lastItem = computed(() => Math.min(props.totalItems, firstItem.value + 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) {
|
if (props.filter) {
|
||||||
return {
|
return {
|
||||||
path: props.link,
|
path: props.link,
|
||||||
query: props.filter,
|
query: {
|
||||||
|
filter: JSON.stringify(props.filter),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return props.link;
|
return props.link;
|
||||||
|
@ -47,11 +49,19 @@ const linkTarget = computed(() => {
|
||||||
padding: $statistics-card-padding;
|
padding: $statistics-card-padding;
|
||||||
|
|
||||||
border-radius: $statistics-card-border-radius;
|
border-radius: $statistics-card-border-radius;
|
||||||
|
border-width: 0.2em;
|
||||||
|
border-style: solid;
|
||||||
|
|
||||||
@each $variant, $color in $variant-colors {
|
@each $variant, $color in $variant-colors {
|
||||||
&.statistics-card-#{$variant} {
|
&.statistics-card-#{$variant} {
|
||||||
background-color: $color;
|
background-color: $color;
|
||||||
color: map-get($variant-text-colors, $variant);
|
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 config = useConfigStore();
|
||||||
const version = useVersionStore();
|
const version = useVersionStore();
|
||||||
|
|
||||||
function refresh(): void {
|
|
||||||
config.refresh();
|
|
||||||
version.refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
refresh();
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
|
@ -2,12 +2,6 @@
|
||||||
import { useConfigStore } from "@/stores/config";
|
import { useConfigStore } from "@/stores/config";
|
||||||
|
|
||||||
const config = useConfigStore();
|
const config = useConfigStore();
|
||||||
|
|
||||||
function refresh(): void {
|
|
||||||
config.refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
refresh();
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
|
@ -3,10 +3,15 @@ import { createPinia } from "pinia";
|
||||||
|
|
||||||
import App from "./App.vue";
|
import App from "./App.vue";
|
||||||
import router from "./router";
|
import router from "./router";
|
||||||
|
import {useConfigStore} from "@/stores/config";
|
||||||
|
import {useVersionStore} from "@/stores/version";
|
||||||
|
|
||||||
const app = createApp(App);
|
const app = createApp(App);
|
||||||
|
|
||||||
app.use(createPinia());
|
app.use(createPinia());
|
||||||
app.use(router);
|
app.use(router);
|
||||||
|
|
||||||
|
useConfigStore().refresh().catch(err => console.error(err));
|
||||||
|
useVersionStore().refresh().catch(err => console.error(err));
|
||||||
|
|
||||||
app.mount("#app");
|
app.mount("#app");
|
||||||
|
|
|
@ -21,9 +21,25 @@ const router = createRouter({
|
||||||
path: "/admin/nodes",
|
path: "/admin/nodes",
|
||||||
name: "admin-nodes",
|
name: "admin-nodes",
|
||||||
component: AdminNodesView,
|
component: AdminNodesView,
|
||||||
props: route => ({
|
props: route => {
|
||||||
filter: isNodesFilter(route.query) ? route.query : {}
|
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
|
// Grays
|
||||||
$black: #000000;
|
$black: #000000;
|
||||||
$gray-darkest: #222222;
|
$gray-darkest: #1c1c1c;
|
||||||
$gray-darker: #333333;
|
$gray-darker: #2c2c2c;
|
||||||
$gray-dark: #444444;
|
$gray-dark: #333333;
|
||||||
$gray: #666666;
|
$gray: #666666;
|
||||||
$gray-light: #d6d6d6;
|
$gray-light: #d6d6d6;
|
||||||
$gray-lighter: #ededed;
|
$gray-lighter: #ededed;
|
||||||
|
@ -14,7 +14,7 @@ $variant-colors: (
|
||||||
success: #4ba74b,
|
success: #4ba74b,
|
||||||
warning: #fdbc41,
|
warning: #fdbc41,
|
||||||
danger: #ef5652,
|
danger: #ef5652,
|
||||||
info: #009ee0,
|
info: #0097c4,
|
||||||
);
|
);
|
||||||
$variant-text-colors: (
|
$variant-text-colors: (
|
||||||
// primary: do not use, contrast too low
|
// primary: do not use, contrast too low
|
||||||
|
@ -37,9 +37,24 @@ $page-padding: 0.5em 0.5em 4.5em 0.5em;
|
||||||
|
|
||||||
// Links
|
// Links
|
||||||
$link-color: $variant-color-warning;
|
$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
|
// Navigation
|
||||||
$nav-bar-background-color: $gray-darker;
|
$nav-bar-background-color: $gray-dark;
|
||||||
$nav-link-color: $page-text-color;
|
$nav-link-color: $page-text-color;
|
||||||
$nav-link-spacing: 1.5em;
|
$nav-link-spacing: 1.5em;
|
||||||
|
|
||||||
|
@ -57,3 +72,9 @@ $statistics-card-border-radius: 0.5em;
|
||||||
$statistics-card-icon-size: 4em;
|
$statistics-card-icon-size: 4em;
|
||||||
$statistics-card-icon-gap: 0.15em;
|
$statistics-card-icon-gap: 0.15em;
|
||||||
$statistics-card-value-font-size: 2.5em;
|
$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: {
|
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>(
|
const result = await internalApi.getPagedList<EnhancedNode>(
|
||||||
"nodes",
|
"nodes",
|
||||||
isEnhancedNode,
|
isEnhancedNode,
|
||||||
page,
|
page,
|
||||||
nodesPerPage,
|
nodesPerPage,
|
||||||
filter,
|
query,
|
||||||
);
|
);
|
||||||
this.nodes = result.entries;
|
this.nodes = result.entries;
|
||||||
this.totalNodes = result.total;
|
this.totalNodes = result.total;
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { defineStore } from "pinia";
|
import {defineStore} from "pinia";
|
||||||
import { isObject, isVersion, type Version } from "@/types";
|
import {isObject, isVersion, type Version} from "@/types";
|
||||||
import { api } from "@/utils/Api";
|
import {api} from "@/utils/Api";
|
||||||
|
|
||||||
interface VersionResponse {
|
interface VersionResponse {
|
||||||
version: Version;
|
version: Version;
|
||||||
|
|
|
@ -1,17 +1,22 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {useNodesStore} from "@/stores/nodes";
|
import {useNodesStore} from "@/stores/nodes";
|
||||||
import {onMounted, ref} from "vue";
|
import {onMounted, ref, watch} from "vue";
|
||||||
import type {EnhancedNode, MAC, NodesFilter} from "@/types";
|
import type {EnhancedNode, MAC, NodesFilter} from "@/types";
|
||||||
import Pager from "@/components/Pager.vue";
|
import Pager from "@/components/Pager.vue";
|
||||||
import LoadingContainer from "@/components/LoadingContainer.vue";
|
import LoadingContainer from "@/components/LoadingContainer.vue";
|
||||||
|
import NodesFilterPanel from "@/components/nodes/NodesFilterPanel.vue";
|
||||||
|
import router from "@/router";
|
||||||
|
|
||||||
const NODE_PER_PAGE = 50;
|
const NODE_PER_PAGE = 50;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
filter: NodesFilter;
|
filter: NodesFilter;
|
||||||
|
searchTerm?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>();
|
const props = defineProps<Props>();
|
||||||
|
const currentFilter = ref<NodesFilter>({});
|
||||||
|
const currentSearchTerm = ref<string | undefined>(undefined);
|
||||||
|
|
||||||
type NodeRedactField = "nickname" | "email" | "token";
|
type NodeRedactField = "nickname" | "email" | "token";
|
||||||
type NodeRedactFieldsMap = Partial<Record<NodeRedactField, boolean>>;
|
type NodeRedactFieldsMap = Partial<Record<NodeRedactField, boolean>>;
|
||||||
|
@ -27,7 +32,7 @@ async function refresh(page: number): Promise<void> {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
redactAllFields(true);
|
redactAllFields(true);
|
||||||
try {
|
try {
|
||||||
await nodes.refresh(page, NODE_PER_PAGE, props.filter);
|
await nodes.refresh(page, NODE_PER_PAGE, currentFilter.value, currentSearchTerm.value);
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
|
@ -56,25 +61,37 @@ function setRedactField(node: EnhancedNode, field: NodeRedactField, value: boole
|
||||||
redactFieldsMap[field] = value;
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<h2>Knoten</h2>
|
<h2>Knoten</h2>
|
||||||
|
|
||||||
<div>
|
<NodesFilterPanel :filter="props.filter" @update-filter="updateFilter"/>
|
||||||
<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>
|
|
||||||
|
|
||||||
<Pager
|
<Pager
|
||||||
:page="nodes.getPage"
|
:page="nodes.getPage"
|
||||||
|
@ -82,6 +99,21 @@ onMounted(async () => await refresh(1));
|
||||||
:totalItems="nodes.getTotalNodes"
|
:totalItems="nodes.getTotalNodes"
|
||||||
@changePage="refresh"/>
|
@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">
|
<LoadingContainer :loading="loading">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
|
@ -208,9 +240,17 @@ onMounted(async () => await refresh(1));
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@import "../scss/variables";
|
@import "../scss/variables";
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
margin: 0 0 0.5em 0;
|
||||||
|
}
|
||||||
|
|
||||||
table {
|
table {
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
|
|
||||||
|
tbody tr:hover {
|
||||||
|
background-color: $gray-darker;
|
||||||
|
}
|
||||||
|
|
||||||
th, td {
|
th, td {
|
||||||
padding: 0.5em 0.25em;
|
padding: 0.5em 0.25em;
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,6 +19,10 @@ export function isArray<T>(arg: unknown, isT: TypeGuard<T>): arg is Array<T> {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isMap(arg: unknown): arg is Map<any, any> {
|
||||||
|
return arg instanceof Map;
|
||||||
|
}
|
||||||
|
|
||||||
export function isString(arg: unknown): arg is string {
|
export function isString(arg: unknown): arg is string {
|
||||||
return typeof arg === "string"
|
return typeof arg === "string"
|
||||||
}
|
}
|
||||||
|
@ -333,8 +337,20 @@ export interface NodesFilter {
|
||||||
hasKey?: boolean;
|
hasKey?: boolean;
|
||||||
hasCoords?: boolean;
|
hasCoords?: boolean;
|
||||||
monitoringState?: MonitoringState;
|
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 {
|
export function isNodesFilter(arg: unknown): arg is NodesFilter {
|
||||||
if (!isObject(arg)) {
|
if (!isObject(arg)) {
|
||||||
return false;
|
return false;
|
||||||
|
@ -343,6 +359,9 @@ export function isNodesFilter(arg: unknown): arg is NodesFilter {
|
||||||
return (
|
return (
|
||||||
isOptional(filter.hasKey, isBoolean) &&
|
isOptional(filter.hasKey, isBoolean) &&
|
||||||
isOptional(filter.hasCoords, 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