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

View file

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

View file

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

View file

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

View file

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

View file

@ -6,11 +6,11 @@ interface Props {
align: ComponentAlignment; align: ComponentAlignment;
} }
const props = defineProps<Props>() const props = defineProps<Props>();
</script> </script>
<template> <template>
<div :class="['button-group', buttonSize, align]"> <div :class="['button-group', props.buttonSize, props.align]">
<slot></slot> <slot></slot>
</div> </div>
</template> </template>

View file

@ -19,15 +19,9 @@ async function onClick() {
</script> </script>
<template> <template>
<ActionButton <ActionButton :variant="variant" :size="size" :icon="icon" @click="onClick">
:variant="variant"
:size="size"
:icon="icon"
@click="onClick"
>
<slot /> <slot />
</ActionButton> </ActionButton>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss"></style>
</style>

View file

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

View file

@ -30,7 +30,9 @@ function registerValidationComponent() {
} }
parent = parent.parent; 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() { function onInput() {
@ -39,7 +41,7 @@ function onInput() {
} }
const element = input.value; const element = input.value;
if (!element) { if (!element) {
console.warn("Could not get referenced input element.") console.warn("Could not get referenced input element.");
return; return;
} }
emit("update:modelValue", element.value); emit("update:modelValue", element.value);
@ -48,7 +50,7 @@ function onInput() {
function validate(): boolean { function validate(): boolean {
const element = input.value; const element = input.value;
if (!element) { if (!element) {
console.warn("Could not get referenced input element.") console.warn("Could not get referenced input element.");
return false; return false;
} }
valid.value = forConstraint(props.constraint, false)(element.value); valid.value = forConstraint(props.constraint, false)(element.value);
@ -57,7 +59,7 @@ function validate(): boolean {
} }
defineExpose({ defineExpose({
validate validate,
}); });
onMounted(() => { onMounted(() => {

View file

@ -24,7 +24,7 @@ const email = computed(() => configStore.getConfig?.community.contactEmail);
const errorDeletingNode = ref<boolean>(false); const errorDeletingNode = ref<boolean>(false);
const emit = defineEmits<{ const emit = defineEmits<{
(e: "delete", hostname: Hostname): void (e: "delete", hostname: Hostname): void;
}>(); }>();
async function onSubmit() { async function onSubmit() {
@ -61,23 +61,31 @@ async function onAbort() {
<h2>Soll der Knoten wirklich gelöscht werden?</h2> <h2>Soll der Knoten wirklich gelöscht werden?</h2>
<p> <p>
Soll der Knoten <strong>{{ props.node.hostname }}</strong> wirklich endgültig gelöscht werden? Soll der Knoten <strong>{{ props.node.hostname }}</strong> wirklich
Du kannst ihn selbstverständlich später jederzeit erneut anmelden! endgültig gelöscht werden? Du kannst ihn selbstverständlich später
jederzeit erneut anmelden!
</p> </p>
<ErrorCard v-if="errorDeletingNode"> <ErrorCard v-if="errorDeletingNode">
Beim Löschen des Knotens ist ein Fehler aufgetreten. Bitte probiere es später nochmal. Sollte dieses Problem Beim Löschen des Knotens ist ein Fehler aufgetreten. Bitte probiere
weiter bestehen, so wende dich bitte per E-Mail an <a v-if="email" :href="`mailto:${email}`">{{ email }}</a>. 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> </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 <ActionButton
type="submit" type="submit"
icon="trash" icon="trash"
:variant="ComponentVariant.WARNING" :variant="ComponentVariant.WARNING"
:size="ButtonSize.MEDIUM"> :size="ButtonSize.MEDIUM"
>
Knoten löschen Knoten löschen
</ActionButton> </ActionButton>
<ActionButton <ActionButton
@ -85,7 +93,8 @@ async function onAbort() {
icon="times" icon="times"
:variant="ComponentVariant.SECONDARY" :variant="ComponentVariant.SECONDARY"
:size="ButtonSize.MEDIUM" :size="ButtonSize.MEDIUM"
@click="onAbort"> @click="onAbort"
>
Abbrechen Abbrechen
</ActionButton> </ActionButton>
</ButtonGroup> </ButtonGroup>

View file

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

View file

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

View file

@ -12,36 +12,43 @@ const props = defineProps<Props>();
<template> <template>
<div class="node-preview-card"> <div class="node-preview-card">
<h3> <h3>
<i class="fa fa-dot-circle-o" aria-hidden="true" title="Knotenname:" /> <i
{{ node.hostname }} class="fa fa-dot-circle-o"
aria-hidden="true"
title="Knotenname:"
/>
{{ props.node.hostname }}
</h3> </h3>
<div class="field"> <div class="field">
<strong>Token:</strong> <code>{{ node.token }}</code> <strong>Token:</strong> <code>{{ props.node.token }}</code>
</div> </div>
<div class="field"> <div class="field">
<i class="fa fa-map-marker" aria-hidden="true" title="Standort:" /> <i class="fa fa-map-marker" aria-hidden="true" title="Standort:" />
{{ node.coords || "nicht angegeben" }} {{ props.node.coords || "nicht angegeben" }}
</div> </div>
<div class="field"> <div class="field">
<strong>MAC-Adresse:</strong> <code>{{ node.mac }}</code> <strong>MAC-Adresse:</strong> <code>{{ props.node.mac }}</code>
</div> </div>
<div class="field"> <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>
<div class="field"> <div class="field">
<strong>Monitoring:</strong> <strong>Monitoring:</strong>
<span v-if="node.monitoringState === MonitoringState.PENDING"> <span v-if="props.node.monitoringState === MonitoringState.PENDING">
Bestätigung ausstehend Bestätigung ausstehend
</span> </span>
<span v-if="node.monitoringState === MonitoringState.ACTIVE"> <span v-if="props.node.monitoringState === MonitoringState.ACTIVE">
aktiv aktiv
</span> </span>
<span v-if="node.monitoringState === MonitoringState.DISABLED"> <span
v-if="props.node.monitoringState === MonitoringState.DISABLED"
>
nicht aktiv nicht aktiv
</span> </span>
</div> </div>

View file

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

View file

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

View file

@ -12,17 +12,24 @@ const versionStore = useVersionStore();
<a href="https://github.com/freifunkhamburg/ffffng" target="_blank"> <a href="https://github.com/freifunkhamburg/ffffng" target="_blank">
<i class="fa fa-code" aria-hidden="true" /> Source Code <i class="fa fa-code" aria-hidden="true" /> Source Code
</a> </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 <i class="fa fa-bug" aria-hidden="true" /> Fehler melden
</a> </a>
<a <a
v-if="configStore.getConfig.legal.privacyUrl" v-if="configStore.getConfig.legal.privacyUrl"
:href="configStore.getConfig.legal.privacyUrl" :href="configStore.getConfig.legal.privacyUrl"
target="_blank">Datenschutz</a> target="_blank"
>Datenschutz</a
>
<a <a
v-if="configStore.getConfig.legal.imprintUrl" v-if="configStore.getConfig.legal.imprintUrl"
:href="configStore.getConfig.legal.imprintUrl" :href="configStore.getConfig.legal.imprintUrl"
target="_blank">Impressum</a> target="_blank"
>Impressum</a
>
</footer> </footer>
</template> </template>

View file

@ -18,7 +18,8 @@ const configStore = useConfigStore();
<h1> <h1>
<RouterLink :to="route(RouteName.HOME)"> <RouterLink :to="route(RouteName.HOME)">
{{ configStore.getConfig.community.name }} Knotenverwaltung {{ configStore.getConfig.community.name }}
Knotenverwaltung
</RouterLink> </RouterLink>
</h1> </h1>

View file

@ -3,9 +3,9 @@ import {type Component, defineComponent, type PropType} from "vue";
import { type EnumValue, SortDirection } from "@/types"; import { type EnumValue, SortDirection } from "@/types";
type Props<SortField> = { type Props<SortField> = {
field: PropType<EnumValue<SortField>>, field: PropType<EnumValue<SortField>>;
currentField: PropType<EnumValue<SortField>>, currentField: PropType<EnumValue<SortField>>;
currentDirection: PropType<SortDirection>, currentDirection: PropType<SortDirection>;
}; };
type SortTH<SortField> = Component<Props<SortField>>; type SortTH<SortField> = Component<Props<SortField>>;
@ -21,7 +21,9 @@ function defineGenericComponent<SortField>(): SortTH<SortField> {
props, props,
computed: { computed: {
sortDirection: function () { sortDirection: function () {
return this.field === this.currentField ? this.currentDirection : undefined; return this.field === this.currentField
? this.currentDirection
: undefined;
}, },
isAscending: function () { isAscending: function () {
return this.sortDirection === SortDirection.ASCENDING; return this.sortDirection === SortDirection.ASCENDING;
@ -33,11 +35,13 @@ function defineGenericComponent<SortField>(): SortTH<SortField> {
methods: { methods: {
onClick(): void { onClick(): void {
this.$emit( this.$emit(
'sort', "sort",
this.field, this.field,
this.isAscending ? SortDirection.DESCENDING : SortDirection.ASCENDING this.isAscending
? SortDirection.DESCENDING
: SortDirection.ASCENDING
); );
} },
}, },
}); });
} }

View file

@ -11,7 +11,11 @@ const app = createApp(App);
app.use(createPinia()); app.use(createPinia());
app.use(router); app.use(router);
useConfigStore().refresh().catch(err => console.error(err)); useConfigStore()
useVersionStore().refresh().catch(err => console.error(err)); .refresh()
.catch((err) => console.error(err));
useVersionStore()
.refresh()
.catch((err) => console.error(err));
app.mount("#app"); 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 AdminDashboardView from "@/views/AdminDashboardView.vue";
import AdminNodesView from "@/views/AdminNodesView.vue"; import AdminNodesView from "@/views/AdminNodesView.vue";
import HomeView from "@/views/HomeView.vue"; import HomeView from "@/views/HomeView.vue";
import NodeDeleteView from "@/views/NodeDeleteView.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 { export interface Route {
name: RouteName; name: RouteName;
@ -46,9 +55,11 @@ const router = createRouter({
path: "/admin/nodes", path: "/admin/nodes",
name: RouteName.ADMIN_NODES, name: RouteName.ADMIN_NODES,
component: AdminNodesView, component: AdminNodesView,
props: route => { props: (route) => {
let filter: any; let filter: unknown;
if (route.query.hasOwnProperty("filter")) { if (
Object.prototype.hasOwnProperty.call(route.query, "filter")
) {
try { try {
filter = JSON.parse(route.query.filter as string); filter = JSON.parse(route.query.filter as string);
} catch (e) { } catch (e) {
@ -59,14 +70,20 @@ const router = createRouter({
filter = {}; filter = {};
} }
const searchTerm = route.query.q ? route.query.q as SearchTerm : undefined; const searchTerm = route.query.q
? (route.query.q as SearchTerm)
: undefined;
return { return {
filter: isNodesFilter(filter) ? filter : {}, filter: isNodesFilter(filter) ? filter : {},
searchTerm, searchTerm,
sortDirection: isSortDirection(route.query.sortDir) ? route.query.sortDir : undefined, sortDirection: isSortDirection(route.query.sortDir)
sortField: isNodeSortField(route.query.sortField) ? route.query.sortField : undefined, ? route.query.sortDir
} : undefined,
} sortField: isNodeSortField(route.query.sortField)
? route.query.sortField
: undefined,
};
},
}, },
], ],
}); });

View file

@ -20,10 +20,7 @@ export const useConfigStore = defineStore({
}, },
actions: { actions: {
async refresh(): Promise<void> { async refresh(): Promise<void> {
this.config = await api.get<ClientConfig>( this.config = await api.get<ClientConfig>("config", isClientConfig);
"config",
isClientConfig
);
}, },
}, },
}); });

View file

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

View file

@ -1,5 +1,11 @@
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import {type DomainSpecificNodeResponse, isDomainSpecificNodeResponse, type NodesFilter, NodeSortField, SortDirection} from "@/types"; import {
type DomainSpecificNodeResponse,
isDomainSpecificNodeResponse,
type NodesFilter,
NodeSortField,
SortDirection,
} from "@/types";
import { internalApi } from "@/utils/Api"; import { internalApi } from "@/utils/Api";
interface NodesStoreState { interface NodesStoreState {
@ -33,11 +39,11 @@ export const useNodesStore = defineStore({
}, },
getNodesPerPage(state: NodesStoreState): number { getNodesPerPage(state: NodesStoreState): number {
return state.nodesPerPage return state.nodesPerPage;
}, },
getPage(state: NodesStoreState): number { getPage(state: NodesStoreState): number {
return state.page return state.page;
}, },
}, },
actions: { actions: {
@ -49,20 +55,23 @@ export const useNodesStore = defineStore({
filter: NodesFilter, filter: NodesFilter,
searchTerm?: string searchTerm?: string
): Promise<void> { ): Promise<void> {
const query: Record<string, any> = { const query: Record<string, unknown> = {
...filter, ...filter,
}; };
if (searchTerm) { if (searchTerm) {
query.q = searchTerm; query.q = searchTerm;
} }
const result = await internalApi.getPagedList<DomainSpecificNodeResponse, NodeSortField>( const result = await internalApi.getPagedList<
DomainSpecificNodeResponse,
NodeSortField
>(
"nodes", "nodes",
isDomainSpecificNodeResponse, isDomainSpecificNodeResponse,
page, page,
nodesPerPage, nodesPerPage,
sortDirection, sortDirection,
sortField, sortField,
query, query
); );
this.nodes = result.entries; this.nodes = result.entries;
this.totalNodes = result.total; this.totalNodes = result.total;

View file

@ -17,7 +17,7 @@ export class ApiError extends Error {
private constructor( private constructor(
message: string, message: string,
private status: number, private status: number,
private errorType: ApiErrorType, private errorType: ApiErrorType
) { ) {
super(message); super(message);
} }
@ -25,13 +25,13 @@ export class ApiError extends Error {
static async requestFailed( static async requestFailed(
method: Method, method: Method,
path: string, path: string,
response: Response, response: Response
): Promise<ApiError> { ): Promise<ApiError> {
const body = await response.text(); const body = await response.text();
return new ApiError( return new ApiError(
`API ${method} request failed: ${path} => ${response.status} - ${body}`, `API ${method} request failed: ${path} => ${response.status} - ${body}`,
response.status, response.status,
ApiErrorType.REQUEST_FAILED, ApiErrorType.REQUEST_FAILED
); );
} }
@ -39,17 +39,20 @@ export class ApiError extends Error {
method: Method, method: Method,
path: string, path: string,
response: Response, response: Response,
json: any, json: unknown
): Promise<ApiError> { ): Promise<ApiError> {
return new ApiError( return new ApiError(
`API ${method} request result has unexpected type. ${path} => ${json}`, `API ${method} request result has unexpected type. ${path} => ${json}`,
response.status, response.status,
ApiErrorType.UNEXPECTED_RESULT_TYPE, ApiErrorType.UNEXPECTED_RESULT_TYPE
); );
} }
isNotFoundError(): boolean { 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) { if (queryParams) {
const queryStrings: string[] = []; const queryStrings: string[] = [];
for (const [key, value] of Object.entries(queryParams)) { for (const [key, value] of Object.entries(queryParams)) {
queryStrings.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`); queryStrings.push(
`${encodeURIComponent(key)}=${encodeURIComponent(value)}`
);
} }
if (queryStrings.length > 0) { if (queryStrings.length > 0) {
queryString = `?${queryStrings.join("&")}`; queryString = `?${queryStrings.join("&")}`;
@ -87,12 +92,26 @@ class Api {
return this.baseURL + this.apiPrefix + path + queryString; return this.baseURL + this.apiPrefix + path + queryString;
} }
private async sendRequest(method: Method, path: string, queryParams?: object): Promise<ApiResponse<undefined>>; private async sendRequest(
private async sendRequest<T>(method: Method, path: string, isT: TypeGuard<T>, queryParams?: object): Promise<ApiResponse<T>>; method: Method,
private async sendRequest<T>(method: Method, path: string, isT?: TypeGuard<T>, queryParams?: object): Promise<ApiResponse<T>> { 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 url = this.toURL(path, queryParams);
const response = await fetch(url, { const response = await fetch(url, {
method method,
}); });
if (!response.ok) { if (!response.ok) {
@ -103,7 +122,12 @@ class Api {
const json = await response.json(); const json = await response.json();
if (isT && !isT(json)) { if (isT && !isT(json)) {
console.log(json); console.log(json);
throw await ApiError.unexpectedResultType(method, path, response, json); throw await ApiError.unexpectedResultType(
method,
path,
response,
json
);
} }
return { return {
@ -112,13 +136,17 @@ class Api {
}; };
} else { } else {
return { return {
result: undefined as any as T, result: undefined as unknown as T,
headers: response.headers, 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); return await this.sendRequest<T>("GET", path, isT, queryParams);
} }
@ -134,7 +162,7 @@ class Api {
itemsPerPage: number, itemsPerPage: number,
sortDirection?: SortDirection, sortDirection?: SortDirection,
sortField?: SortField, sortField?: SortField,
filter?: object, filter?: object
): Promise<PagedListResult<Element>> { ): Promise<PagedListResult<Element>> {
const response = await this.doGet(path, toIsArray(isElement), { const response = await this.doGet(path, toIsArray(isElement), {
_page: page, _page: page,
@ -149,7 +177,7 @@ class Api {
return { return {
entries: response.result, entries: response.result,
total, total,
} };
} }
async delete(path: string): Promise<void> { 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! // TODO: Write tests!
export function parseInteger(arg: any, radix: number): number { export function parseInteger(arg: unknown, radix: number): number {
if (Number.isInteger(arg)) { if (isInteger(arg)) {
return arg; return arg;
} }
switch (typeof arg) { switch (typeof arg) {
case "number": case "number":
throw new Error(`Not an integer: ${arg}`); throw new Error(`Not an integer: ${arg}`);
case "string": case "string": {
if (radix < 2 || radix > 36 || isNaN(radix)) { if (radix < 2 || radix > 36 || isNaN(radix)) {
throw new Error(`Radix out of range: ${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}`); throw new Error(`Not a valid number (radix: ${radix}): ${str}`);
} }
if (num.toString(radix).toLowerCase() !== str.toLowerCase()) { 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; return num;
}
default: default:
throw new Error(`Cannot parse number (radix: ${radix}): ${arg}`); throw new Error(`Cannot parse number (radix: ${radix}): ${arg}`);
} }

View file

@ -78,5 +78,4 @@ refresh();
} }
} }
} }
</style> </style>

View file

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

View file

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

View file

@ -20,11 +20,17 @@ async function onDelete(hostname: Hostname) {
<template> <template>
<PageContainer> <PageContainer>
<NodeDeleteForm v-if="!node && !deletedHostname" @submit="onSelectNode"/> <NodeDeleteForm
<NodeDeleteConfirmationForm v-if="node && !deletedHostname" @delete="onDelete" :node="node"/> v-if="!node && !deletedHostname"
@submit="onSelectNode"
/>
<NodeDeleteConfirmationForm
v-if="node && !deletedHostname"
@delete="onDelete"
:node="node"
/>
<NodeDeletedPanel v-if="deletedHostname" :hostname="deletedHostname" /> <NodeDeletedPanel v-if="deletedHostname" :hostname="deletedHostname" />
</PageContainer> </PageContainer>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped></style>
</style>