ESLint: Auto format and fix some warnings / errors.

This commit is contained in:
baldo 2022-08-23 19:08:39 +02:00
parent 90ac67efbe
commit 867be21f68
32 changed files with 737 additions and 489 deletions

View file

@ -1,17 +1,17 @@
<script setup lang="ts">
import {RouterView} from "vue-router";
import { RouterView } from "vue-router";
import PageHeader from "@/components/page/PageHeader.vue";
import PageFooter from "@/components/page/PageFooter.vue";
</script>
<template>
<PageHeader/>
<PageHeader />
<main>
<RouterView/>
<RouterView />
</main>
<PageFooter/>
<PageFooter />
</template>
<style lang="scss">

View file

@ -1,5 +1,4 @@
<script setup lang="ts">
</script>
<script setup lang="ts"></script>
<template>
<div class="spinner-container">

View file

@ -1,5 +1,5 @@
<script setup lang="ts">
import {computed, defineProps} from "vue";
import { computed, defineProps } from "vue";
const props = defineProps({
page: {
@ -17,10 +17,17 @@ const props = defineProps({
});
const firstItem = computed(() => {
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 lastPage = computed(() => Math.ceil(props.totalItems / props.itemsPerPage));
const lastItem = computed(() =>
Math.min(props.totalItems, firstItem.value + props.itemsPerPage - 1)
);
const lastPage = computed(() =>
Math.ceil(props.totalItems / props.itemsPerPage)
);
const pages = computed(() => {
const pages: number[] = [];
if (lastPage.value <= 2) {
@ -48,11 +55,13 @@ const showFirstEllipsis = computed(
() => pages.value.length > 0 && pages.value[0] > 2
);
const showLastEllipsis = computed(
() => pages.value.length > 0 && pages.value[pages.value.length - 1] < lastPage.value - 1
() =>
pages.value.length > 0 &&
pages.value[pages.value.length - 1] < lastPage.value - 1
);
const emit = defineEmits<{
(e: "changePage", page: number): void,
(e: "changePage", page: number): void;
}>();
function toPage(page: number): void {
@ -60,7 +69,9 @@ function toPage(page: number): void {
}
// noinspection JSIncompatibleTypesComparison
const classes = computed(() => (p: number) => p === props.page ? ["current-page"] : []);
const classes = computed(
() => (p: number) => p === props.page ? ["current-page"] : []
);
</script>
<template>
@ -77,9 +88,22 @@ const classes = computed(() => (p: number) => p === props.page ? ["current-page"
<li v-if="page > 1" @click="toPage(page - 1)"></li>
<li :class="classes(1)" @click="toPage(1)">1</li>
<li v-if="showFirstEllipsis" class="ellipsis"></li>
<li v-for="page in pages" :class="classes(page)" @click="toPage(page)">{{ page }}</li>
<li
v-for="page in pages"
v-bind:key="page"
:class="classes(page)"
@click="toPage(page)"
>
{{ page }}
</li>
<li v-if="showLastEllipsis" class="ellipsis"></li>
<li v-if="lastPage > 1" :class="classes(lastPage)" @click="toPage(lastPage)">{{ lastPage }}</li>
<li
v-if="lastPage > 1"
:class="classes(lastPage)"
@click="toPage(lastPage)"
>
{{ lastPage }}
</li>
<li v-if="page < lastPage" @click="toPage(page + 1)"></li>
</ul>
</nav>

View file

@ -1,18 +1,18 @@
<script setup lang="ts">
import Spinner from "@/components/Spinner.vue";
import ActivityIndicator from "@/components/ActivityIndicator.vue";
const props = defineProps({
loading: {
type: Boolean,
required: true,
}
},
});
</script>
<template>
<div :class="{ 'loading-container': true, loading: loading }">
<Spinner class="spinner" v-if="loading" />
<div class="content" v-if="!loading">
<div :class="{ 'loading-container': true, loading: props.loading }">
<ActivityIndicator v-if="props.loading" />
<div class="content" v-if="!props.loading">
<slot></slot>
</div>
</div>
@ -31,5 +31,4 @@ const props = defineProps({
height: 10em;
}
}
</style>

View file

@ -1,8 +1,8 @@
<script setup lang="ts">
import {computed, defineProps} from "vue";
import type {ComponentVariant, NodesFilter} from "@/types";
import type {RouteName} from "@/router";
import router, {route} from "@/router";
import { computed, defineProps } from "vue";
import type { ComponentVariant, NodesFilter } from "@/types";
import type { RouteName } from "@/router";
import { route } from "@/router";
interface Props {
title: string;
@ -24,8 +24,11 @@ const linkTarget = computed(() => {
</script>
<template>
<RouterLink :to="linkTarget" :class="['statistics-card', 'statistics-card-' + variant]">
<i :class="['fa', 'fa-' + icon]" aria-hidden="true"/>
<RouterLink
:to="linkTarget"
:class="['statistics-card', 'statistics-card-' + variant]"
>
<i :class="['fa', 'fa-' + icon]" aria-hidden="true" />
<dl>
<dt>{{ title }}</dt>
<dd>{{ value }}</dd>

View file

@ -1,5 +1,5 @@
<script setup lang="ts">
import type {ButtonSize, ComponentVariant} from "@/types";
import type { ButtonSize, ComponentVariant } from "@/types";
interface Props {
variant: ComponentVariant;
@ -7,10 +7,10 @@ interface Props {
icon: string;
}
const props = defineProps<Props>()
const props = defineProps<Props>();
const emit = defineEmits<{
(e: "click"): void,
(e: "click"): void;
}>();
function onClick() {
@ -19,8 +19,8 @@ function onClick() {
</script>
<template>
<button :class="[size, variant]" @click="onClick">
<i :class="['fa', `fa-${icon}`]" aria-hidden="true"/>
<button :class="[props.size, props.variant]" @click="onClick">
<i :class="['fa', `fa-${props.icon}`]" aria-hidden="true" />
<slot></slot>
</button>
</template>
@ -44,7 +44,8 @@ button {
border-color: $color;
color: map-get($variant-text-colors, $variant);
&:hover, &:active {
&:hover,
&:active {
background-color: $page-background-color;
border-color: $color;
color: $color;

View file

@ -1,16 +1,16 @@
<script setup lang="ts">
import type {ButtonSize, ComponentAlignment} from "@/types";
import type { ButtonSize, ComponentAlignment } from "@/types";
interface Props {
buttonSize: ButtonSize;
align: ComponentAlignment;
}
const props = defineProps<Props>()
const props = defineProps<Props>();
</script>
<template>
<div :class="['button-group', buttonSize, align]">
<div :class="['button-group', props.buttonSize, props.align]">
<slot></slot>
</div>
</template>

View file

@ -1,6 +1,6 @@
<script setup lang="ts">
import type {ButtonSize, ComponentVariant} from "@/types";
import type {Route} from "@/router";
import type { ButtonSize, ComponentVariant } from "@/types";
import type { Route } from "@/router";
import router from "@/router";
import ActionButton from "@/components/form/ActionButton.vue";
@ -19,15 +19,9 @@ async function onClick() {
</script>
<template>
<ActionButton
:variant="variant"
:size="size"
:icon="icon"
@click="onClick"
>
<slot/>
<ActionButton :variant="variant" :size="size" :icon="icon" @click="onClick">
<slot />
</ActionButton>
</template>
<style scoped lang="scss">
</style>
<style scoped lang="scss"></style>

View file

@ -1,8 +1,8 @@
<script setup lang="ts">
import {type ComponentInternalInstance, ref, type Slot} from "vue";
import { type ComponentInternalInstance, ref } from "vue";
const emit = defineEmits<{
(e: "submit"): void
(e: "submit"): void;
}>();
const validationComponents = ref<ComponentInternalInstance[]>([]);
@ -10,14 +10,16 @@ const validationComponents = ref<ComponentInternalInstance[]>([]);
defineExpose({
registerValidationComponent(component: ComponentInternalInstance): void {
validationComponents.value.push(component);
}
},
});
function validate(): boolean {
let valid = true;
for (const component of validationComponents.value) {
if (!(component.exposed?.validate)) {
throw new Error(`Component has no exposed validate() method: ${component.type.__name}`);
if (!component.exposed?.validate) {
throw new Error(
`Component has no exposed validate() method: ${component.type.__name}`
);
}
valid = component.exposed.validate() && valid;
}
@ -32,14 +34,12 @@ function onSubmit() {
}
// TODO: Else scroll to first error and focus input.
}
</script>
<template>
<form @submit.prevent="onSubmit">
<slot/>
<slot />
</form>
</template>
<style lang="scss">
</style>
<style lang="scss"></style>

View file

@ -1,6 +1,6 @@
<script setup lang="ts">
import {getCurrentInstance, onMounted, ref} from "vue";
import {type Constraint, forConstraint} from "@/shared/validation/validator";
import { getCurrentInstance, onMounted, ref } from "vue";
import { type Constraint, forConstraint } from "@/shared/validation/validator";
interface Props {
modelValue?: string;
@ -30,7 +30,9 @@ function registerValidationComponent() {
}
parent = parent.parent;
}
throw new Error("Could not find matching ValidationForm for ValidationFormInpunt.");
throw new Error(
"Could not find matching ValidationForm for ValidationFormInpunt."
);
}
function onInput() {
@ -39,7 +41,7 @@ function onInput() {
}
const element = input.value;
if (!element) {
console.warn("Could not get referenced input element.")
console.warn("Could not get referenced input element.");
return;
}
emit("update:modelValue", element.value);
@ -48,7 +50,7 @@ function onInput() {
function validate(): boolean {
const element = input.value;
if (!element) {
console.warn("Could not get referenced input element.")
console.warn("Could not get referenced input element.");
return false;
}
valid.value = forConstraint(props.constraint, false)(element.value);
@ -57,7 +59,7 @@ function validate(): boolean {
}
defineExpose({
validate
validate,
});
onMounted(() => {
@ -68,7 +70,7 @@ onMounted(() => {
<template>
<div>
<label>
{{label}}:
{{ label }}:
<input
ref="input"
:value="modelValue"
@ -78,7 +80,7 @@ onMounted(() => {
/>
</label>
<div class="validation-error" v-if="!valid">
{{validationError}}
{{ validationError }}
</div>
</div>
</template>

View file

@ -1,16 +1,16 @@
<script setup lang="ts">
import ValidationForm from "@/components/form/ValidationForm.vue";
import {useNodeStore} from "@/stores/node";
import { useNodeStore } from "@/stores/node";
import ButtonGroup from "@/components/form/ButtonGroup.vue";
import ActionButton from "@/components/form/ActionButton.vue";
import NodePreviewCard from "@/components/nodes/NodePreviewCard.vue";
import type {Hostname, StoredNode} from "@/types";
import {ButtonSize, ComponentAlignment, ComponentVariant} from "@/types";
import router, {route, RouteName} from "@/router";
import {computed, nextTick, ref} from "vue";
import {ApiError} from "@/utils/Api";
import type { Hostname, StoredNode } from "@/types";
import { ButtonSize, ComponentAlignment, ComponentVariant } from "@/types";
import router, { route, RouteName } from "@/router";
import { computed, nextTick, ref } from "vue";
import { ApiError } from "@/utils/Api";
import ErrorCard from "@/components/ErrorCard.vue";
import {useConfigStore} from "@/stores/config";
import { useConfigStore } from "@/stores/config";
interface Props {
node: StoredNode;
@ -24,7 +24,7 @@ const email = computed(() => configStore.getConfig?.community.contactEmail);
const errorDeletingNode = ref<boolean>(false);
const emit = defineEmits<{
(e: "delete", hostname: Hostname): void
(e: "delete", hostname: Hostname): void;
}>();
async function onSubmit() {
@ -61,23 +61,31 @@ async function onAbort() {
<h2>Soll der Knoten wirklich gelöscht werden?</h2>
<p>
Soll der Knoten <strong>{{ props.node.hostname }}</strong> wirklich endgültig gelöscht werden?
Du kannst ihn selbstverständlich später jederzeit erneut anmelden!
Soll der Knoten <strong>{{ props.node.hostname }}</strong> wirklich
endgültig gelöscht werden? Du kannst ihn selbstverständlich später
jederzeit erneut anmelden!
</p>
<ErrorCard v-if="errorDeletingNode">
Beim Löschen des Knotens ist ein Fehler aufgetreten. Bitte probiere es später nochmal. Sollte dieses Problem
weiter bestehen, so wende dich bitte per E-Mail an <a v-if="email" :href="`mailto:${email}`">{{ email }}</a>.
Beim Löschen des Knotens ist ein Fehler aufgetreten. Bitte probiere
es später nochmal. Sollte dieses Problem weiter bestehen, so wende
dich bitte per E-Mail an
<a v-if="email" :href="`mailto:${email}`">{{ email }}</a
>.
</ErrorCard>
<NodePreviewCard class="preview" :node="props.node"/>
<NodePreviewCard class="preview" :node="props.node" />
<ButtonGroup :align="ComponentAlignment.CENTER" :button-size="ButtonSize.SMALL">
<ButtonGroup
:align="ComponentAlignment.CENTER"
:button-size="ButtonSize.SMALL"
>
<ActionButton
type="submit"
icon="trash"
:variant="ComponentVariant.WARNING"
:size="ButtonSize.MEDIUM">
:size="ButtonSize.MEDIUM"
>
Knoten löschen
</ActionButton>
<ActionButton
@ -85,7 +93,8 @@ async function onAbort() {
icon="times"
:variant="ComponentVariant.SECONDARY"
:size="ButtonSize.MEDIUM"
@click="onAbort">
@click="onAbort"
>
Abbrechen
</ActionButton>
</ButtonGroup>

View file

@ -1,18 +1,18 @@
<script setup lang="ts">
import {useConfigStore} from "@/stores/config";
import {useNodeStore} from "@/stores/node";
import {computed, nextTick, ref} from "vue";
import { useConfigStore } from "@/stores/config";
import { useNodeStore } from "@/stores/node";
import { computed, nextTick, ref } from "vue";
import CONSTRAINTS from "@/shared/validation/constraints";
import ActionButton from "@/components/form/ActionButton.vue";
import type {StoredNode, Token} from "@/types";
import {ButtonSize, ComponentAlignment, ComponentVariant} from "@/types";
import type { StoredNode, Token } from "@/types";
import { ButtonSize, ComponentAlignment, ComponentVariant } from "@/types";
import ErrorCard from "@/components/ErrorCard.vue";
import ButtonGroup from "@/components/form/ButtonGroup.vue";
import ValidationForm from "@/components/form/ValidationForm.vue";
import ValidationFormInput from "@/components/form/ValidationFormInput.vue";
import {route, RouteName} from "@/router";
import { route, RouteName } from "@/router";
import RouteButton from "@/components/form/RouteButton.vue";
import {ApiError} from "@/utils/Api";
import { ApiError } from "@/utils/Api";
const configStore = useConfigStore();
const email = computed(() => configStore.getConfig?.community.contactEmail);
@ -57,15 +57,18 @@ async function onSubmit() {
<div>
<p>
Um die Daten Deines Knotens zu löschen, benötigen wir den passenden Token (eine 16-stellige Folge aus
Ziffern und Buchstaben). Diesen hast Du beim ersten Anmelden Deines Knotens erhalten. Sinn des Tokens
ist,
Dich davor zu schützen, dass Dritte unbefugt Deine Daten einsehen oder ändern können.
Um die Daten Deines Knotens zu löschen, benötigen wir den
passenden Token (eine 16-stellige Folge aus Ziffern und
Buchstaben). Diesen hast Du beim ersten Anmelden Deines Knotens
erhalten. Sinn des Tokens ist, Dich davor zu schützen, dass
Dritte unbefugt Deine Daten einsehen oder ändern können.
</p>
<p>
<strong>
Solltest Du den Token nicht mehr haben, wende Dich einfach per E-Mail an
<a v-if="email" :href="`mailto:${email}`">{{ email }}</a>.
Solltest Du den Token nicht mehr haben, wende Dich einfach
per E-Mail an
<a v-if="email" :href="`mailto:${email}`">{{ email }}</a
>.
</strong>
</p>
@ -74,9 +77,11 @@ async function onSubmit() {
</ErrorCard>
<ErrorCard v-if="generalError">
Beim Abrufen des Knotens ist ein Fehler aufgetreten. Bitte probiere es später nochmal. Sollte dieses
Problem weiter bestehen, so wende dich bitte per E-Mail an
<a v-if="email" :href="`mailto:${email}`">{{ email }}</a>.
Beim Abrufen des Knotens ist ein Fehler aufgetreten. Bitte
probiere es später nochmal. Sollte dieses Problem weiter
bestehen, so wende dich bitte per E-Mail an
<a v-if="email" :href="`mailto:${email}`">{{ email }}</a
>.
</ErrorCard>
<fieldset>
@ -87,12 +92,16 @@ async function onSubmit() {
:constraint="CONSTRAINTS.token"
validation-error="Das Token ist ein 16-stelliger Wert bestehend aus 0-9 und a-f."
/>
<ButtonGroup :align="ComponentAlignment.RIGHT" :button-size="ButtonSize.SMALL">
<ButtonGroup
:align="ComponentAlignment.RIGHT"
:button-size="ButtonSize.SMALL"
>
<ActionButton
type="submit"
icon="trash"
:variant="ComponentVariant.WARNING"
:size="ButtonSize.SMALL">
:size="ButtonSize.SMALL"
>
Knoten löschen
</ActionButton>
<RouteButton
@ -100,7 +109,8 @@ async function onSubmit() {
icon="times"
:variant="ComponentVariant.SECONDARY"
:size="ButtonSize.SMALL"
:route="route(RouteName.HOME)">
:route="route(RouteName.HOME)"
>
Abbrechen
</RouteButton>
</ButtonGroup>
@ -108,9 +118,11 @@ async function onSubmit() {
<p>
<em>
Hinweis: Nach dem Löschen kann der Knoten ggf. weiterhin in der Knotenkarte angezeigt werden. Dies
ist dann der Fall, wenn der Knoten eingeschaltet ist und in Reichweite eines anderen aktiven Knotens
steht. Die angezeigten Daten sind dann die während der Einrichtung des Knotens im Config-Mode
Hinweis: Nach dem Löschen kann der Knoten ggf. weiterhin in
der Knotenkarte angezeigt werden. Dies ist dann der Fall,
wenn der Knoten eingeschaltet ist und in Reichweite eines
anderen aktiven Knotens steht. Die angezeigten Daten sind
dann die während der Einrichtung des Knotens im Config-Mode
(Konfigurationsoberfläche des Routers) hinterlegten.
</em>
</p>
@ -118,5 +130,4 @@ async function onSubmit() {
</ValidationForm>
</template>
<style lang="scss" scoped>
</style>
<style lang="scss" scoped></style>

View file

@ -1,11 +1,11 @@
<script setup lang="ts">
import ButtonGroup from "@/components/form/ButtonGroup.vue";
import type {Hostname} from "@/types";
import {ButtonSize, ComponentAlignment, ComponentVariant} from "@/types";
import {useConfigStore} from "@/stores/config";
import {computed} from "vue";
import type { Hostname } from "@/types";
import { ButtonSize, ComponentAlignment, ComponentVariant } from "@/types";
import { useConfigStore } from "@/stores/config";
import { computed } from "vue";
import RouteButton from "@/components/form/RouteButton.vue";
import {route, RouteName} from "@/router";
import { route, RouteName } from "@/router";
const configStore = useConfigStore();
const email = computed(() => configStore.getConfig?.community.contactEmail);
@ -22,38 +22,48 @@ const props = defineProps<Props>();
<div>
<h1>Erledigt!</h1>
<p>
Die Daten Deines Freifunk-Knotens sind gelöscht worden. Es kann jetzt noch bis zu 20 Minuten dauern,
bis die Änderungen überall wirksam werden und sich in der
<a :href="configStore.getConfig?.map.mapUrl" target="_blank">Knotenkarte</a> auswirken.
Die Daten Deines Freifunk-Knotens sind gelöscht worden. Es kann
jetzt noch bis zu 20 Minuten dauern, bis die Änderungen überall
wirksam werden und sich in der
<a :href="mapUrl" target="_blank">Knotenkarte</a> auswirken.
</p>
<div class="summary">
<div class="deleted-node">
<i class="fa fa-trash-o" aria-hidden="true"/> <span class="node">{{ hostname }}</span>
<i class="fa fa-trash-o" aria-hidden="true" />
<span class="node">{{ props.hostname }}</span>
</div>
</div>
<p>
<em>
Hinweis: Nach dem Löschen kann der Knoten ggf. weiterhin als online in der Knotenkarte angezeigt werden.
Dies ist dann der Fall, wenn der Knoten eingeschaltet ist und in Reichweite eines anderen aktiven
Knotens steht. Die angezeigten Daten sind dann die während der Einrichtung des Knotens im Config-Mode
(Konfigurationsoberfläche des Routers) hinterlegten. Außerdem kann es nach dem Löschen noch etwa einen
Monat dauern, bis der Knoten in der Karte auch nicht mehr als offline erscheint.
Hinweis: Nach dem Löschen kann der Knoten ggf. weiterhin als
online in der Knotenkarte angezeigt werden. Dies ist dann der
Fall, wenn der Knoten eingeschaltet ist und in Reichweite eines
anderen aktiven Knotens steht. Die angezeigten Daten sind dann
die während der Einrichtung des Knotens im Config-Mode
(Konfigurationsoberfläche des Routers) hinterlegten. Außerdem
kann es nach dem Löschen noch etwa einen Monat dauern, bis der
Knoten in der Karte auch nicht mehr als offline erscheint.
</em>
</p>
<p>
Bei Fragen wende Dich gerne an
<a :href="`mailto:${ email }`">{{ email }}</a>.
<a :href="`mailto:${email}`">{{ email }}</a
>.
</p>
<ButtonGroup :align="ComponentAlignment.CENTER" :button-size="ButtonSize.MEDIUM">
<ButtonGroup
:align="ComponentAlignment.CENTER"
:button-size="ButtonSize.MEDIUM"
>
<RouteButton
icon="reply"
:variant="ComponentVariant.SECONDARY"
:size="ButtonSize.MEDIUM"
:route="route(RouteName.HOME)">
:route="route(RouteName.HOME)"
>
Zurück zum Anfang
</RouteButton>
</ButtonGroup>

View file

@ -1,6 +1,6 @@
<script setup lang="ts">
import type {StoredNode} from "@/types";
import {MonitoringState} from "@/types";
import type { StoredNode } from "@/types";
import { MonitoringState } from "@/types";
interface Props {
node: StoredNode;
@ -12,36 +12,43 @@ const props = defineProps<Props>();
<template>
<div class="node-preview-card">
<h3>
<i class="fa fa-dot-circle-o" aria-hidden="true" title="Knotenname:" />
{{ node.hostname }}
<i
class="fa fa-dot-circle-o"
aria-hidden="true"
title="Knotenname:"
/>
{{ props.node.hostname }}
</h3>
<div class="field">
<strong>Token:</strong> <code>{{ node.token }}</code>
<strong>Token:</strong> <code>{{ props.node.token }}</code>
</div>
<div class="field">
<i class="fa fa-map-marker" aria-hidden="true" title="Standort:" />
{{ node.coords || "nicht angegeben" }}
{{ props.node.coords || "nicht angegeben" }}
</div>
<div class="field">
<strong>MAC-Adresse:</strong> <code>{{ node.mac }}</code>
<strong>MAC-Adresse:</strong> <code>{{ props.node.mac }}</code>
</div>
<div class="field">
<strong>VPN-Schlüssel:</strong> <code>{{ node.key || "nicht angegeben" }}</code>
<strong>VPN-Schlüssel:</strong>
<code>{{ props.node.key || "nicht angegeben" }}</code>
</div>
<div class="field">
<strong>Monitoring:</strong>
<span v-if="node.monitoringState === MonitoringState.PENDING">
<span v-if="props.node.monitoringState === MonitoringState.PENDING">
Bestätigung ausstehend
</span>
<span v-if="node.monitoringState === MonitoringState.ACTIVE">
<span v-if="props.node.monitoringState === MonitoringState.ACTIVE">
aktiv
</span>
<span v-if="node.monitoringState === MonitoringState.DISABLED">
<span
v-if="props.node.monitoringState === MonitoringState.DISABLED"
>
nicht aktiv
</span>
</div>

View file

@ -10,8 +10,8 @@ import {
type SearchTerm,
type UnixTimestampMilliseconds,
} from "@/types";
import {computed, nextTick, onMounted, ref, watch} from "vue";
import {useConfigStore} from "@/stores/config";
import { computed, nextTick, onMounted, ref, watch } from "vue";
import { useConfigStore } from "@/stores/config";
interface Props {
searchTerm: SearchTerm;
@ -20,7 +20,7 @@ interface Props {
const SEARCH_THROTTLE_DELAY_MS = 500;
const FILTER_LABELS: Record<string, string | Map<any, any>> = {
const FILTER_LABELS: Record<string, string | Map<string | boolean, any>> = {
hasKey: new Map([
[true, "Mit VPN-Schlüssel"],
[false, "Ohne VPN-Schlüssel"],
@ -40,10 +40,10 @@ const FILTER_LABELS: Record<string, string | Map<any, any>> = {
[OnlineState.ONLINE, "online"],
[OnlineState.OFFLINE, "offline"],
]),
}
};
const emit = defineEmits<{
(e: "updateFilter", filter: NodesFilter, searchTerm: SearchTerm): void,
(e: "updateFilter", filter: NodesFilter, searchTerm: SearchTerm): void;
}>();
const props = defineProps<Props>();
@ -53,15 +53,18 @@ const suggestedFiltersExpanded = ref(false);
const configStore = useConfigStore();
type Filter = {
field: string,
value: any,
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) {
if (
selectedFilter.field === filter.field &&
selectedFilter.value === filter.value
) {
return i;
}
}
@ -80,6 +83,12 @@ function selectedFilterIndexForField(field: string): number {
return -1;
}
function pushFilter(filterGroup: Filter[], filter: Filter): void {
if (selectedFilterIndex(filter) < 0) {
filterGroup.push(filter);
}
}
const suggestedFilters = computed<Filter[][]>(() => {
const cfg = configStore.getConfig;
const sites = cfg?.community.sites || [];
@ -88,18 +97,12 @@ const suggestedFilters = computed<Filter[][]>(() => {
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);
}
}
const filterGroup: Filter[] = [];
switch (field) {
case "site":
for (const site of sites) {
pushFilter({
pushFilter(filterGroup, {
field: "site",
value: site,
});
@ -108,25 +111,26 @@ const suggestedFilters = computed<Filter[][]>(() => {
case "domain":
for (const domain of domains) {
pushFilter({
pushFilter(filterGroup, {
field: "domain",
value: domain,
});
}
break;
default:
default: {
const labels = FILTER_LABELS[field];
if (!isMap(labels)) {
throw new Error(`Missing case for field ${field}.`);
}
for (const value of labels.keys()) {
pushFilter({
pushFilter(filterGroup, {
field,
value,
});
}
}
}
filterGroups.push(filterGroup);
@ -152,7 +156,7 @@ 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)) {
if (Object.prototype.hasOwnProperty.call(filter, field)) {
addSelectedFilter({
field,
value: filter[field],
@ -165,7 +169,7 @@ watch(props, updateSelectedFilters);
onMounted(updateSelectedFilters);
function renderFilter(filter: Filter): string {
if (!FILTER_LABELS.hasOwnProperty(filter.field)) {
if (!Object.prototype.hasOwnProperty.call(FILTER_LABELS, filter.field)) {
throw new Error(`Filter has no translation: ${filter.field}`);
}
@ -175,7 +179,11 @@ function renderFilter(filter: Filter): string {
}
if (!label.has(filter.value)) {
throw new Error(`Filter ${filter.field} has no translation for value: ${filter.value}(${typeof filter.value})`);
throw new Error(
`Filter ${filter.field} has no translation for value: ${
filter.value
}(${typeof filter.value})`
);
}
return label.get(filter.value);
@ -204,7 +212,8 @@ function buildNodesFilter(): NodesFilter {
return nodesFilter;
}
let lastSearchTimestamp: UnixTimestampMilliseconds = 0 as UnixTimestampMilliseconds;
let lastSearchTimestamp: UnixTimestampMilliseconds =
0 as UnixTimestampMilliseconds;
let searchTimeout: NodeJS.Timeout | undefined = undefined;
let lastSearchTerm: SearchTerm = "" as SearchTerm;
@ -217,17 +226,18 @@ function doSearch(): void {
function doThrottledSearch(): void {
if (lastSearchTerm === input.value.value) {
return
return;
}
// TODO: Share utils.
const now: UnixTimestampMilliseconds = Date.now() as UnixTimestampMilliseconds;
const now: UnixTimestampMilliseconds =
Date.now() as UnixTimestampMilliseconds;
if (now - SEARCH_THROTTLE_DELAY_MS >= lastSearchTimestamp) {
lastSearchTimestamp = now;
doSearch();
} else if (!searchTimeout) {
searchTimeout = setTimeout(() => {
searchTimeout = undefined
searchTimeout = undefined;
doThrottledSearch();
}, SEARCH_THROTTLE_DELAY_MS);
}
@ -235,19 +245,25 @@ function doThrottledSearch(): void {
</script>
<template>
<div :class="{ 'nodes-filter-panel': true, 'focus': hasFocus}" @click="focusInput">
<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"
v-bind:key="`${filter.field}:${filter.value}`"
class="selected-filter"
@click="removeSelectedFilter(filter)"
:title="renderFilter(filter)">
:title="renderFilter(filter)"
>
{{ renderFilter(filter) }}
<i
class="fa fa-times remove-filter"
aria-hidden="true"
title="Filter entfernen"/>
title="Filter entfernen"
/>
</span>
</div>
<input
@ -258,21 +274,28 @@ function doThrottledSearch(): void {
maxlength="64"
type="search"
:value="searchTerm"
placeholder="Knoten durchsuchen..."/>
<i class="fa fa-search search" @click="doSearch()"/>
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">
<div
class="suggested-filter-group"
v-for="filterGroup in suggestedFilters"
>
<span
class="suggested-filter"
v-for="filter in filterGroup"
v-bind:key="filter.field"
@click="addSelectedFilter(filter)"
:title="renderFilter(filter)">
:title="renderFilter(filter)"
>
{{ renderFilter(filter) }}
<i
class="fa fa-plus add-filter"
aria-hidden="true"
title="Filter hinzufügen"/>
title="Filter hinzufügen"
/>
</span>
</div>
</div>
@ -281,14 +304,16 @@ function doThrottledSearch(): void {
v-if="suggestedFiltersExpanded"
class="toggle-suggested-filters"
href="javascript:"
@click="showSuggestedFilters(false)">
@click="showSuggestedFilters(false)"
>
Erweiterte Suche ausblenden
</a>
<a
v-if="!suggestedFiltersExpanded"
class="toggle-suggested-filters"
href="javascript:"
@click="showSuggestedFilters(true)">
@click="showSuggestedFilters(true)"
>
Erweiterte Suche einblenden
</a>
</template>
@ -329,12 +354,14 @@ function doThrottledSearch(): void {
user-select: none;
white-space: nowrap;
.add-filter, .remove-filter {
.add-filter,
.remove-filter {
margin-left: 0.25em;
}
}
.selected-filters, .suggested-filter-group {
.selected-filters,
.suggested-filter-group {
display: flex;
flex-wrap: wrap;
align-items: flex-start;

View file

@ -1,5 +1,4 @@
<script setup lang="ts">
</script>
<script setup lang="ts"></script>
<template>
<div class="page-container">

View file

@ -1,6 +1,6 @@
<script setup lang="ts">
import {useConfigStore} from "@/stores/config";
import {useVersionStore} from "@/stores/version";
import { useConfigStore } from "@/stores/config";
import { useVersionStore } from "@/stores/version";
const configStore = useConfigStore();
const versionStore = useVersionStore();
@ -12,17 +12,24 @@ const versionStore = useVersionStore();
<a href="https://github.com/freifunkhamburg/ffffng" target="_blank">
<i class="fa fa-code" aria-hidden="true" /> Source Code
</a>
<a href="https://github.com/freifunkhamburg/ffffng/issues" target="_blank">
<a
href="https://github.com/freifunkhamburg/ffffng/issues"
target="_blank"
>
<i class="fa fa-bug" aria-hidden="true" /> Fehler melden
</a>
<a
v-if="configStore.getConfig.legal.privacyUrl"
:href="configStore.getConfig.legal.privacyUrl"
target="_blank">Datenschutz</a>
target="_blank"
>Datenschutz</a
>
<a
v-if="configStore.getConfig.legal.imprintUrl"
:href="configStore.getConfig.legal.imprintUrl"
target="_blank">Impressum</a>
target="_blank"
>Impressum</a
>
</footer>
</template>

View file

@ -1,6 +1,6 @@
<script setup lang="ts">
import {useConfigStore} from "@/stores/config";
import {route, RouteName} from "@/router";
import { useConfigStore } from "@/stores/config";
import { route, RouteName } from "@/router";
const configStore = useConfigStore();
</script>
@ -18,12 +18,13 @@ const configStore = useConfigStore();
<h1>
<RouterLink :to="route(RouteName.HOME)">
{{ configStore.getConfig.community.name }} Knotenverwaltung
{{ configStore.getConfig.community.name }}
Knotenverwaltung
</RouterLink>
</h1>
<RouterLink class="admin-link" :to="route(RouteName.ADMIN)">
<i class="fa fa-wrench" aria-hidden="true"/> Admin-Panel
<i class="fa fa-wrench" aria-hidden="true" /> Admin-Panel
</RouterLink>
</nav>
</header>

View file

@ -1,11 +1,11 @@
<script lang="ts">
import {type Component, defineComponent, type PropType} from "vue";
import {type EnumValue, SortDirection} from "@/types";
import { type Component, defineComponent, type PropType } from "vue";
import { type EnumValue, SortDirection } from "@/types";
type Props<SortField> = {
field: PropType<EnumValue<SortField>>,
currentField: PropType<EnumValue<SortField>>,
currentDirection: PropType<SortDirection>,
field: PropType<EnumValue<SortField>>;
currentField: PropType<EnumValue<SortField>>;
currentDirection: PropType<SortDirection>;
};
type SortTH<SortField> = Component<Props<SortField>>;
@ -21,7 +21,9 @@ function defineGenericComponent<SortField>(): SortTH<SortField> {
props,
computed: {
sortDirection: function () {
return this.field === this.currentField ? this.currentDirection : undefined;
return this.field === this.currentField
? this.currentDirection
: undefined;
},
isAscending: function () {
return this.sortDirection === SortDirection.ASCENDING;
@ -33,11 +35,13 @@ function defineGenericComponent<SortField>(): SortTH<SortField> {
methods: {
onClick(): void {
this.$emit(
'sort',
"sort",
this.field,
this.isAscending ? SortDirection.DESCENDING : SortDirection.ASCENDING
this.isAscending
? SortDirection.DESCENDING
: SortDirection.ASCENDING
);
}
},
},
});
}
@ -55,7 +59,7 @@ export default component;
<template>
<th>
<a href="javascript:" title="Sortieren" @click="onClick">
<slot/>
<slot />
<i v-if="sortDirection && isAscending" class="fa fa-chevron-down" />
<i v-if="sortDirection && !isAscending" class="fa fa-chevron-up" />
</a>

View file

@ -3,15 +3,19 @@ import { createPinia } from "pinia";
import App from "./App.vue";
import router from "./router";
import {useConfigStore} from "@/stores/config";
import {useVersionStore} from "@/stores/version";
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));
useConfigStore()
.refresh()
.catch((err) => console.error(err));
useVersionStore()
.refresh()
.catch((err) => console.error(err));
app.mount("#app");

View file

@ -1,9 +1,18 @@
import {createRouter, createWebHistory, type LocationQueryRaw} from "vue-router";
import {
createRouter,
createWebHistory,
type LocationQueryRaw,
} from "vue-router";
import AdminDashboardView from "@/views/AdminDashboardView.vue";
import AdminNodesView from "@/views/AdminNodesView.vue";
import HomeView from "@/views/HomeView.vue";
import NodeDeleteView from "@/views/NodeDeleteView.vue";
import {isNodesFilter, isNodeSortField, isSortDirection, type SearchTerm} from "@/types";
import {
isNodesFilter,
isNodeSortField,
isSortDirection,
type SearchTerm,
} from "@/types";
export interface Route {
name: RouteName;
@ -46,9 +55,11 @@ const router = createRouter({
path: "/admin/nodes",
name: RouteName.ADMIN_NODES,
component: AdminNodesView,
props: route => {
let filter: any;
if (route.query.hasOwnProperty("filter")) {
props: (route) => {
let filter: unknown;
if (
Object.prototype.hasOwnProperty.call(route.query, "filter")
) {
try {
filter = JSON.parse(route.query.filter as string);
} catch (e) {
@ -59,14 +70,20 @@ const router = createRouter({
filter = {};
}
const searchTerm = route.query.q ? route.query.q as SearchTerm : undefined;
const searchTerm = route.query.q
? (route.query.q as SearchTerm)
: undefined;
return {
filter: isNodesFilter(filter) ? filter : {},
searchTerm,
sortDirection: isSortDirection(route.query.sortDir) ? route.query.sortDir : undefined,
sortField: isNodeSortField(route.query.sortField) ? route.query.sortField : undefined,
}
}
sortDirection: isSortDirection(route.query.sortDir)
? route.query.sortDir
: undefined,
sortField: isNodeSortField(route.query.sortField)
? route.query.sortField
: undefined,
};
},
},
],
});

View file

@ -1,6 +1,6 @@
import {defineStore} from "pinia";
import {type ClientConfig, isClientConfig} from "@/types";
import {api} from "@/utils/Api";
import { defineStore } from "pinia";
import { type ClientConfig, isClientConfig } from "@/types";
import { api } from "@/utils/Api";
interface ConfigStoreState {
config: ClientConfig | null;
@ -20,10 +20,7 @@ export const useConfigStore = defineStore({
},
actions: {
async refresh(): Promise<void> {
this.config = await api.get<ClientConfig>(
"config",
isClientConfig
);
this.config = await api.get<ClientConfig>("config", isClientConfig);
},
},
});

View file

@ -1,9 +1,9 @@
import {defineStore} from "pinia";
import {isStoredNode, type StoredNode, type Token} from "@/types";
import {api} from "@/utils/Api";
import { defineStore } from "pinia";
import { isStoredNode, type StoredNode, type Token } from "@/types";
import { api } from "@/utils/Api";
interface NodeStoreState {
}
// eslint-disable-next-line @typescript-eslint/no-empty-interface
interface NodeStoreState {}
export const useNodeStore = defineStore({
id: "node",
@ -18,6 +18,6 @@ export const useNodeStore = defineStore({
async deleteByToken(token: Token): Promise<void> {
await api.delete(`node/${token}`);
}
},
},
});

View file

@ -1,6 +1,12 @@
import {defineStore} from "pinia";
import {type DomainSpecificNodeResponse, isDomainSpecificNodeResponse, type NodesFilter, NodeSortField, SortDirection} from "@/types";
import {internalApi} from "@/utils/Api";
import { defineStore } from "pinia";
import {
type DomainSpecificNodeResponse,
isDomainSpecificNodeResponse,
type NodesFilter,
NodeSortField,
SortDirection,
} from "@/types";
import { internalApi } from "@/utils/Api";
interface NodesStoreState {
nodes: DomainSpecificNodeResponse[];
@ -33,11 +39,11 @@ export const useNodesStore = defineStore({
},
getNodesPerPage(state: NodesStoreState): number {
return state.nodesPerPage
return state.nodesPerPage;
},
getPage(state: NodesStoreState): number {
return state.page
return state.page;
},
},
actions: {
@ -49,20 +55,23 @@ export const useNodesStore = defineStore({
filter: NodesFilter,
searchTerm?: string
): Promise<void> {
const query: Record<string, any> = {
const query: Record<string, unknown> = {
...filter,
};
if (searchTerm) {
query.q = searchTerm;
}
const result = await internalApi.getPagedList<DomainSpecificNodeResponse, NodeSortField>(
const result = await internalApi.getPagedList<
DomainSpecificNodeResponse,
NodeSortField
>(
"nodes",
isDomainSpecificNodeResponse,
page,
nodesPerPage,
sortDirection,
sortField,
query,
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,6 +1,6 @@
import {SortDirection, toIsArray, type TypeGuard} from "@/types";
import type {Headers} from "request";
import {parseInteger} from "@/utils/Numbers";
import { SortDirection, toIsArray, type TypeGuard } from "@/types";
import type { Headers } from "request";
import { parseInteger } from "@/utils/Numbers";
type Method = "GET" | "PUT" | "POST" | "DELETE";
@ -17,7 +17,7 @@ export class ApiError extends Error {
private constructor(
message: string,
private status: number,
private errorType: ApiErrorType,
private errorType: ApiErrorType
) {
super(message);
}
@ -25,13 +25,13 @@ export class ApiError extends Error {
static async requestFailed(
method: Method,
path: string,
response: Response,
response: Response
): Promise<ApiError> {
const body = await response.text();
return new ApiError(
`API ${method} request failed: ${path} => ${response.status} - ${body}`,
response.status,
ApiErrorType.REQUEST_FAILED,
ApiErrorType.REQUEST_FAILED
);
}
@ -39,17 +39,20 @@ export class ApiError extends Error {
method: Method,
path: string,
response: Response,
json: any,
json: unknown
): Promise<ApiError> {
return new ApiError(
`API ${method} request result has unexpected type. ${path} => ${json}`,
response.status,
ApiErrorType.UNEXPECTED_RESULT_TYPE,
ApiErrorType.UNEXPECTED_RESULT_TYPE
);
}
isNotFoundError(): boolean {
return this.errorType === ApiErrorType.REQUEST_FAILED && this.status === HttpStatusCode.NOT_FOUND;
return (
this.errorType === ApiErrorType.REQUEST_FAILED &&
this.status === HttpStatusCode.NOT_FOUND
);
}
}
@ -78,7 +81,9 @@ class Api {
if (queryParams) {
const queryStrings: string[] = [];
for (const [key, value] of Object.entries(queryParams)) {
queryStrings.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
queryStrings.push(
`${encodeURIComponent(key)}=${encodeURIComponent(value)}`
);
}
if (queryStrings.length > 0) {
queryString = `?${queryStrings.join("&")}`;
@ -87,12 +92,26 @@ class Api {
return this.baseURL + this.apiPrefix + path + queryString;
}
private async sendRequest(method: Method, path: string, queryParams?: object): Promise<ApiResponse<undefined>>;
private async sendRequest<T>(method: Method, path: string, isT: TypeGuard<T>, queryParams?: object): Promise<ApiResponse<T>>;
private async sendRequest<T>(method: Method, path: string, isT?: TypeGuard<T>, queryParams?: object): Promise<ApiResponse<T>> {
private async sendRequest(
method: Method,
path: string,
queryParams?: object
): Promise<ApiResponse<undefined>>;
private async sendRequest<T>(
method: Method,
path: string,
isT: TypeGuard<T>,
queryParams?: object
): Promise<ApiResponse<T>>;
private async sendRequest<T>(
method: Method,
path: string,
isT?: TypeGuard<T>,
queryParams?: object
): Promise<ApiResponse<T>> {
const url = this.toURL(path, queryParams);
const response = await fetch(url, {
method
method,
});
if (!response.ok) {
@ -103,7 +122,12 @@ class Api {
const json = await response.json();
if (isT && !isT(json)) {
console.log(json);
throw await ApiError.unexpectedResultType(method, path, response, json);
throw await ApiError.unexpectedResultType(
method,
path,
response,
json
);
}
return {
@ -112,13 +136,17 @@ class Api {
};
} else {
return {
result: undefined as any as T,
result: undefined as unknown as T,
headers: response.headers,
}
};
}
}
private async doGet<T>(path: string, isT: TypeGuard<T>, queryParams?: object): Promise<ApiResponse<T>> {
private async doGet<T>(
path: string,
isT: TypeGuard<T>,
queryParams?: object
): Promise<ApiResponse<T>> {
return await this.sendRequest<T>("GET", path, isT, queryParams);
}
@ -134,7 +162,7 @@ class Api {
itemsPerPage: number,
sortDirection?: SortDirection,
sortField?: SortField,
filter?: object,
filter?: object
): Promise<PagedListResult<Element>> {
const response = await this.doGet(path, toIsArray(isElement), {
_page: page,
@ -149,7 +177,7 @@ class Api {
return {
entries: response.result,
total,
}
};
}
async delete(path: string): Promise<void> {

View file

@ -1,12 +1,16 @@
export function isInteger(arg: unknown): arg is number {
return typeof arg === "number" && Number.isInteger(arg);
}
// TODO: Write tests!
export function parseInteger(arg: any, radix: number): number {
if (Number.isInteger(arg)) {
export function parseInteger(arg: unknown, radix: number): number {
if (isInteger(arg)) {
return arg;
}
switch (typeof arg) {
case "number":
throw new Error(`Not an integer: ${arg}`);
case "string":
case "string": {
if (radix < 2 || radix > 36 || isNaN(radix)) {
throw new Error(`Radix out of range: ${radix}`);
}
@ -16,9 +20,12 @@ export function parseInteger(arg: any, radix: number): number {
throw new Error(`Not a valid number (radix: ${radix}): ${str}`);
}
if (num.toString(radix).toLowerCase() !== str.toLowerCase()) {
throw new Error(`Parsed integer does not match given string (radix: {radix}): ${str}`);
throw new Error(
`Parsed integer does not match given string (radix: {radix}): ${str}`
);
}
return num;
}
default:
throw new Error(`Cannot parse number (radix: ${radix}): ${arg}`);
}

View file

@ -1,8 +1,8 @@
<script setup lang="ts">
import StatisticsCard from "@/components/admin/StatisticsCard.vue";
import {useStatisticsStore} from "@/stores/statistics";
import {ComponentVariant, MonitoringState} from "@/types";
import {RouteName} from "@/router";
import { useStatisticsStore } from "@/stores/statistics";
import { ComponentVariant, MonitoringState } from "@/types";
import { RouteName } from "@/router";
import PageContainer from "@/components/page/PageContainer.vue";
const statisticsStore = useStatisticsStore();
@ -32,7 +32,7 @@ refresh();
:variant="ComponentVariant.WARNING"
:value="statisticsStore.getStatistics.nodes.withVPN"
:route="RouteName.ADMIN_NODES"
:filter="{hasKey: true}"
:filter="{ hasKey: true }"
/>
<StatisticsCard
title="Mit Koordinaten"
@ -40,7 +40,7 @@ refresh();
:variant="ComponentVariant.SUCCESS"
:value="statisticsStore.getStatistics.nodes.withCoords"
:route="RouteName.ADMIN_NODES"
:filter="{hasCoords: true}"
:filter="{ hasCoords: true }"
/>
<StatisticsCard
title="Monitoring aktiv"
@ -48,7 +48,7 @@ refresh();
:variant="ComponentVariant.SUCCESS"
:value="statisticsStore.getStatistics.nodes.monitoring.active"
:route="RouteName.ADMIN_NODES"
:filter="{monitoringState: MonitoringState.ACTIVE}"
:filter="{ monitoringState: MonitoringState.ACTIVE }"
/>
<StatisticsCard
title="Monitoring noch nicht bestätigt"
@ -56,7 +56,7 @@ refresh();
:variant="ComponentVariant.DANGER"
:value="statisticsStore.getStatistics.nodes.monitoring.pending"
:route="RouteName.ADMIN_NODES"
:filter="{monitoringState: MonitoringState.PENDING}"
:filter="{ monitoringState: MonitoringState.PENDING }"
/>
</div>
</PageContainer>
@ -78,5 +78,4 @@ refresh();
}
}
}
</style>

View file

@ -1,14 +1,24 @@
<script setup lang="ts">
import {useNodesStore} from "@/stores/nodes";
import {onMounted, type PropType, ref, watch} from "vue";
import type {DomainSpecificNodeResponse, MAC, NodesFilter, SearchTerm} from "@/types";
import {ButtonSize, ComponentVariant, NodeSortField, SortDirection} from "@/types";
import Pager from "@/components/Pager.vue";
import { useNodesStore } from "@/stores/nodes";
import { onMounted, type PropType, ref, watch } from "vue";
import type {
DomainSpecificNodeResponse,
MAC,
NodesFilter,
SearchTerm,
} from "@/types";
import {
ButtonSize,
ComponentVariant,
NodeSortField,
SortDirection,
} from "@/types";
import ListPager from "@/components/ListPager.vue";
import ActionButton from "@/components/form/ActionButton.vue";
import LoadingContainer from "@/components/LoadingContainer.vue";
import NodesFilterPanel from "@/components/nodes/NodesFilterPanel.vue";
import {SortTH} from "@/components/table/SortTH.vue";
import router, {route, RouteName} from "@/router";
import { SortTH } from "@/components/table/SortTH.vue";
import router, { route, RouteName } from "@/router";
const NODE_PER_PAGE = 50;
@ -41,7 +51,7 @@ type NodeRedactFieldsMap = Partial<Record<NodeRedactField, boolean>>;
type NodesRedactFieldsMap = Partial<Record<MAC, NodeRedactFieldsMap>>;
const nodesStore = useNodesStore();
const redactFieldsByDefault = ref(true);
const nodesRedactFieldsMap = ref({} as NodesRedactFieldsMap)
const nodesRedactFieldsMap = ref({} as NodesRedactFieldsMap);
const loading = ref(false);
@ -55,7 +65,7 @@ async function refresh(page: number): Promise<void> {
props.sortDirection,
props.sortField,
props.filter,
props.searchTerm,
props.searchTerm
);
} finally {
loading.value = false;
@ -67,16 +77,25 @@ function redactAllFields(shallRedactFields: boolean): void {
nodesRedactFieldsMap.value = {};
}
function shallRedactField(node: DomainSpecificNodeResponse, field: NodeRedactField): boolean {
function shallRedactField(
node: DomainSpecificNodeResponse,
field: NodeRedactField
): boolean {
const redactFieldsMap = nodesRedactFieldsMap.value[node.mac];
if (!redactFieldsMap) {
return redactFieldsByDefault.value;
}
const redactField = redactFieldsMap[field];
return redactField === undefined ? redactFieldsByDefault.value : redactField;
return redactField === undefined
? redactFieldsByDefault.value
: redactField;
}
function setRedactField(node: DomainSpecificNodeResponse, field: NodeRedactField, value: boolean): void {
function setRedactField(
node: DomainSpecificNodeResponse,
field: NodeRedactField,
value: boolean
): void {
let redactFieldsMap = nodesRedactFieldsMap.value[node.mac];
if (!redactFieldsMap) {
redactFieldsMap = {};
@ -89,28 +108,42 @@ async function updateRouterState(
filter: NodesFilter,
searchTerm: SearchTerm,
sortDirection: SortDirection,
sortField: NodeSortField,
sortField: NodeSortField
): Promise<void> {
const filterStr = Object.keys(filter).length > 0 ? JSON.stringify(filter) : undefined;
const filterStr =
Object.keys(filter).length > 0 ? JSON.stringify(filter) : undefined;
await router.replace(
route(
RouteName.ADMIN_NODES,
{
q: searchTerm || undefined,
filter: filterStr,
sortDir: sortDirection,
sortField: sortField,
}
)
route(RouteName.ADMIN_NODES, {
q: searchTerm || undefined,
filter: filterStr,
sortDir: sortDirection,
sortField: sortField,
})
);
}
async function updateFilter(filter: NodesFilter, searchTerm: SearchTerm): Promise<void> {
await updateRouterState(filter, searchTerm, props.sortDirection, props.sortField);
async function updateFilter(
filter: NodesFilter,
searchTerm: SearchTerm
): Promise<void> {
await updateRouterState(
filter,
searchTerm,
props.sortDirection,
props.sortField
);
}
async function updateSortOrder(sortField: NodeSortField, sortDirection: SortDirection): Promise<void> {
await updateRouterState(props.filter, props.searchTerm, sortDirection, sortField);
async function updateSortOrder(
sortField: NodeSortField,
sortDirection: SortDirection
): Promise<void> {
await updateRouterState(
props.filter,
props.searchTerm,
sortDirection,
sortField
);
}
onMounted(async () => {
@ -124,13 +157,18 @@ watch(props, async () => {
<template>
<h2>Knoten</h2>
<NodesFilterPanel :search-term="searchTerm" :filter="filter" @update-filter="updateFilter"/>
<NodesFilterPanel
:search-term="searchTerm"
:filter="filter"
@update-filter="updateFilter"
/>
<Pager
<ListPager
:page="nodesStore.getPage"
:itemsPerPage="nodesStore.getNodesPerPage"
:totalItems="nodesStore.getTotalNodes"
@changePage="refresh"/>
@changePage="refresh"
/>
<div class="actions">
<ActionButton
@ -138,7 +176,8 @@ watch(props, async () => {
:variant="ComponentVariant.WARNING"
:size="ButtonSize.SMALL"
icon="eye"
@click="redactAllFields(false)">
@click="redactAllFields(false)"
>
Sensible Daten einblenden
</ActionButton>
<ActionButton
@ -146,7 +185,8 @@ watch(props, async () => {
:variant="ComponentVariant.SUCCESS"
:size="ButtonSize.SMALL"
icon="eye-slash"
@click="redactAllFields(true)">
@click="redactAllFields(true)"
>
Sensible Daten ausblenden
</ActionButton>
</div>
@ -154,190 +194,223 @@ watch(props, async () => {
<LoadingContainer :loading="loading">
<table>
<thead>
<tr>
<sth
:field="NodeSortField.HOSTNAME"
:currentField="sortField"
:currentDirection="sortDirection"
@sort="updateSortOrder">
Name
</sth>
<sth
:field="NodeSortField.NICKNAME"
:currentField="sortField"
:currentDirection="sortDirection"
@sort="updateSortOrder">
Besitzer*in
</sth>
<sth
:field="NodeSortField.EMAIL"
:currentField="sortField"
:currentDirection="sortDirection"
@sort="updateSortOrder">
E-Mail
</sth>
<sth
:field="NodeSortField.TOKEN"
:currentField="sortField"
:currentDirection="sortDirection"
@sort="updateSortOrder">
Token
</sth>
<sth
:field="NodeSortField.MAC"
:currentField="sortField"
:currentDirection="sortDirection"
@sort="updateSortOrder">
MAC
</sth>
<sth
:field="NodeSortField.KEY"
:currentField="sortField"
:currentDirection="sortDirection"
@sort="updateSortOrder">
VPN
</sth>
<sth
:field="NodeSortField.SITE"
:currentField="sortField"
:currentDirection="sortDirection"
@sort="updateSortOrder">
Site
</sth>
<sth
:field="NodeSortField.DOMAIN"
:currentField="sortField"
:currentDirection="sortDirection"
@sort="updateSortOrder">
Domäne
</sth>
<sth
:field="NodeSortField.COORDS"
:currentField="sortField"
:currentDirection="sortDirection"
@sort="updateSortOrder">
GPS
</sth>
<sth
:field="NodeSortField.ONLINE_STATE"
:currentField="sortField"
:currentDirection="sortDirection"
@sort="updateSortOrder">
Status
</sth>
<sth
:field="NodeSortField.MONITORING_STATE"
:currentField="sortField"
:currentDirection="sortDirection"
@sort="updateSortOrder">
Monitoring
</sth>
</tr>
<tr>
<sth
:field="NodeSortField.HOSTNAME"
:currentField="sortField"
:currentDirection="sortDirection"
@sort="updateSortOrder"
>
Name
</sth>
<sth
:field="NodeSortField.NICKNAME"
:currentField="sortField"
:currentDirection="sortDirection"
@sort="updateSortOrder"
>
Besitzer*in
</sth>
<sth
:field="NodeSortField.EMAIL"
:currentField="sortField"
:currentDirection="sortDirection"
@sort="updateSortOrder"
>
E-Mail
</sth>
<sth
:field="NodeSortField.TOKEN"
:currentField="sortField"
:currentDirection="sortDirection"
@sort="updateSortOrder"
>
Token
</sth>
<sth
:field="NodeSortField.MAC"
:currentField="sortField"
:currentDirection="sortDirection"
@sort="updateSortOrder"
>
MAC
</sth>
<sth
:field="NodeSortField.KEY"
:currentField="sortField"
:currentDirection="sortDirection"
@sort="updateSortOrder"
>
VPN
</sth>
<sth
:field="NodeSortField.SITE"
:currentField="sortField"
:currentDirection="sortDirection"
@sort="updateSortOrder"
>
Site
</sth>
<sth
:field="NodeSortField.DOMAIN"
:currentField="sortField"
:currentDirection="sortDirection"
@sort="updateSortOrder"
>
Domäne
</sth>
<sth
:field="NodeSortField.COORDS"
:currentField="sortField"
:currentDirection="sortDirection"
@sort="updateSortOrder"
>
GPS
</sth>
<sth
:field="NodeSortField.ONLINE_STATE"
:currentField="sortField"
:currentDirection="sortDirection"
@sort="updateSortOrder"
>
Status
</sth>
<sth
:field="NodeSortField.MONITORING_STATE"
:currentField="sortField"
:currentDirection="sortDirection"
@sort="updateSortOrder"
>
Monitoring
</sth>
</tr>
</thead>
<tbody>
<tr
v-for="node in nodesStore.getNodes"
:class="[node.onlineState ? node.onlineState.toLowerCase() : 'online-state-unknown']">
<td>{{ node.hostname }}</td>
<td v-if="shallRedactField(node, 'nickname')">
<span
class="redacted"
@click="setRedactField(node, 'nickname', false)">
nickname
</span>
</td>
<td v-if="!shallRedactField(node, 'nickname')">
<span
class="redactable"
@click="setRedactField(node, 'nickname', true)">
{{ node.nickname }}
</span>
</td>
<td v-if="shallRedactField(node, 'email')">
<span
class="redacted"
@click="setRedactField(node, 'email', false)">
email@example.com
</span>
</td>
<td v-if="!shallRedactField(node, 'email')">
<span
class="redactable"
@click="setRedactField(node, 'email', true)">
{{ node.email }}
</span>
</td>
<td v-if="shallRedactField(node, 'token')">
<span
class="redacted"
@click="setRedactField(node, 'token', false)">
0123456789abcdef
</span>
</td>
<td v-if="!shallRedactField(node, 'token')">
<span
class="redactable"
@click="setRedactField(node, 'token', true)">
{{ node.token }}
</span>
</td>
<td>{{ node.mac }}</td>
<td class="icon">
<i
v-if="node.key"
class="fa fa-lock"
aria-hidden="true"
title="Hat VPN-Schlüssel"/>
<i
v-if="!node.key"
class="fa fa-times not-available"
aria-hidden="true"
title="Hat keinen VPN-Schlüssel"/>
</td>
<td>{{ node.site }}</td>
<td>{{ node.domain }}</td>
<td class="icon">
<i
v-if="node.coords"
class="fa fa-map-marker"
aria-hidden="true"
title="Hat Koordinaten"/>
<i
v-if="!node.coords"
class="fa fa-times not-available"
aria-hidden="true"
title="Hat keinen Koordinaten"/>
</td>
<td v-if="node.onlineState !== undefined">{{ node.onlineState.toLowerCase() }}</td>
<td v-if="node.onlineState === undefined">unbekannt</td>
<td class="icon">
<i
v-if="node.monitoring && node.monitoringConfirmed"
class="fa fa-heartbeat"
aria-hidden="true"
title="Monitoring aktiv"/>
<i
v-if="node.monitoring && !node.monitoringConfirmed"
class="fa fa-envelope"
aria-hidden="true"
title="Monitoring nicht bestätigt"/>
<i
v-if="!node.monitoring"
class="fa fa-times not-available"
aria-hidden="true"
title="Monitoring deaktiviert"/>
</td>
</tr>
<tr
v-for="node in nodesStore.getNodes"
v-bind:key="node.mac"
:class="[
node.onlineState
? node.onlineState.toLowerCase()
: 'online-state-unknown',
]"
>
<td>{{ node.hostname }}</td>
<td v-if="shallRedactField(node, 'nickname')">
<span
class="redacted"
@click="setRedactField(node, 'nickname', false)"
>
nickname
</span>
</td>
<td v-if="!shallRedactField(node, 'nickname')">
<span
class="redactable"
@click="setRedactField(node, 'nickname', true)"
>
{{ node.nickname }}
</span>
</td>
<td v-if="shallRedactField(node, 'email')">
<span
class="redacted"
@click="setRedactField(node, 'email', false)"
>
email@example.com
</span>
</td>
<td v-if="!shallRedactField(node, 'email')">
<span
class="redactable"
@click="setRedactField(node, 'email', true)"
>
{{ node.email }}
</span>
</td>
<td v-if="shallRedactField(node, 'token')">
<span
class="redacted"
@click="setRedactField(node, 'token', false)"
>
0123456789abcdef
</span>
</td>
<td v-if="!shallRedactField(node, 'token')">
<span
class="redactable"
@click="setRedactField(node, 'token', true)"
>
{{ node.token }}
</span>
</td>
<td>{{ node.mac }}</td>
<td class="icon">
<i
v-if="node.key"
class="fa fa-lock"
aria-hidden="true"
title="Hat VPN-Schlüssel"
/>
<i
v-if="!node.key"
class="fa fa-times not-available"
aria-hidden="true"
title="Hat keinen VPN-Schlüssel"
/>
</td>
<td>{{ node.site }}</td>
<td>{{ node.domain }}</td>
<td class="icon">
<i
v-if="node.coords"
class="fa fa-map-marker"
aria-hidden="true"
title="Hat Koordinaten"
/>
<i
v-if="!node.coords"
class="fa fa-times not-available"
aria-hidden="true"
title="Hat keinen Koordinaten"
/>
</td>
<td v-if="node.onlineState !== undefined">
{{ node.onlineState.toLowerCase() }}
</td>
<td v-if="node.onlineState === undefined">unbekannt</td>
<td class="icon">
<i
v-if="node.monitoring && node.monitoringConfirmed"
class="fa fa-heartbeat"
aria-hidden="true"
title="Monitoring aktiv"
/>
<i
v-if="node.monitoring && !node.monitoringConfirmed"
class="fa fa-envelope"
aria-hidden="true"
title="Monitoring nicht bestätigt"
/>
<i
v-if="!node.monitoring"
class="fa fa-times not-available"
aria-hidden="true"
title="Monitoring deaktiviert"
/>
</td>
</tr>
</tbody>
</table>
</LoadingContainer>
<Pager
<ListPager
:page="nodesStore.getPage"
:itemsPerPage="nodesStore.getNodesPerPage"
:totalItems="nodesStore.getTotalNodes"
@changePage="refresh"/>
@changePage="refresh"
/>
</template>
<style lang="scss" scoped>
@ -354,7 +427,8 @@ table {
background-color: $gray-darker;
}
th, td {
th,
td {
padding: 0.5em 0.25em;
}
@ -378,7 +452,8 @@ table {
text-align: center;
}
.redacted, .redactable {
.redacted,
.redactable {
cursor: pointer;
}
@ -387,7 +462,7 @@ table {
}
.not-available {
color: $gray-dark
color: $gray-dark;
}
}
</style>

View file

@ -2,38 +2,47 @@
import ActionButton from "@/components/form/ActionButton.vue";
import ButtonGroup from "@/components/form/ButtonGroup.vue";
import PageContainer from "@/components/page/PageContainer.vue";
import {ButtonSize, ComponentAlignment, ComponentVariant} from "@/types";
import {route, RouteName} from "@/router";
import RouteButton from "@/components/form/RouteButton.vue";</script>
import { ButtonSize, ComponentAlignment, ComponentVariant } from "@/types";
import { route, RouteName } from "@/router";
import RouteButton from "@/components/form/RouteButton.vue";
</script>
<template>
<PageContainer>
<h2>Willkommen!</h2>
<p>
Du hast einen neuen Freifunk Hamburg Router (Knoten), den Du in Betrieb nehmen möchtest? Du hast schon
einen Knoten in Betrieb und möchtest seine Daten ändern? Oder Du möchtest einen Knoten, der nicht mehr
in Betrieb ist löschen? Dann bist Du hier richtig!
Du hast einen neuen Freifunk Hamburg Router (Knoten), den Du in
Betrieb nehmen möchtest? Du hast schon einen Knoten in Betrieb und
möchtest seine Daten ändern? Oder Du möchtest einen Knoten, der
nicht mehr in Betrieb ist löschen? Dann bist Du hier richtig!
</p>
<ButtonGroup class="actions" :align="ComponentAlignment.CENTER" :button-size="ButtonSize.LARGE">
<ButtonGroup
class="actions"
:align="ComponentAlignment.CENTER"
:button-size="ButtonSize.LARGE"
>
<ActionButton
:variant="ComponentVariant.INFO"
:size="ButtonSize.LARGE"
icon="dot-circle-o">
icon="dot-circle-o"
>
Neuen Knoten anmelden
</ActionButton>
<ActionButton
:variant="ComponentVariant.PRIMARY"
:size="ButtonSize.LARGE"
icon="pencil">
icon="pencil"
>
Knotendaten ändern
</ActionButton>
<RouteButton
:variant="ComponentVariant.WARNING"
:size="ButtonSize.LARGE"
icon="trash"
:route="route(RouteName.NODE_DELETE)">
:route="route(RouteName.NODE_DELETE)"
>
Knoten löschen
</RouteButton>
</ButtonGroup>

View file

@ -3,8 +3,8 @@ import PageContainer from "@/components/page/PageContainer.vue";
import NodeDeleteForm from "@/components/nodes/NodeDeleteForm.vue";
import NodeDeleteConfirmationForm from "@/components/nodes/NodeDeleteConfirmationForm.vue";
import NodeDeletedPanel from "@/components/nodes/NodeDeletedPanel.vue";
import {ref} from "vue";
import type {Hostname, StoredNode} from "@/types";
import { ref } from "vue";
import type { Hostname, StoredNode } from "@/types";
const node = ref<StoredNode | undefined>(undefined);
const deletedHostname = ref<Hostname | undefined>(undefined);
@ -20,11 +20,17 @@ async function onDelete(hostname: Hostname) {
<template>
<PageContainer>
<NodeDeleteForm v-if="!node && !deletedHostname" @submit="onSelectNode"/>
<NodeDeleteConfirmationForm v-if="node && !deletedHostname" @delete="onDelete" :node="node"/>
<NodeDeletedPanel v-if="deletedHostname" :hostname="deletedHostname"/>
<NodeDeleteForm
v-if="!node && !deletedHostname"
@submit="onSelectNode"
/>
<NodeDeleteConfirmationForm
v-if="node && !deletedHostname"
@delete="onDelete"
:node="node"
/>
<NodeDeletedPanel v-if="deletedHostname" :hostname="deletedHostname" />
</PageContainer>
</template>
<style lang="scss" scoped>
</style>
<style lang="scss" scoped></style>

View file

@ -1,6 +1,6 @@
import {fileURLToPath, URL} from "url";
import { fileURLToPath, URL } from "url";
import {defineConfig} from "vite";
import { defineConfig } from "vite";
import basicSsl from "@vitejs/plugin-basic-ssl";
import vue from "@vitejs/plugin-vue";