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>
<script setup lang="ts"></script>
<template>
<div class="spinner-container">

View file

@ -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

@ -2,7 +2,7 @@
import { computed, defineProps } from "vue";
import type { ComponentVariant, NodesFilter } from "@/types";
import type { RouteName } from "@/router";
import router, {route} from "@/router";
import { route } from "@/router";
interface Props {
title: string;
@ -24,7 +24,10 @@ const linkTarget = computed(() => {
</script>
<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" />
<dl>
<dt>{{ title }}</dt>

View file

@ -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

@ -6,11 +6,11 @@ interface Props {
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

@ -19,15 +19,9 @@ async function onClick() {
</script>
<template>
<ActionButton
:variant="variant"
:size="size"
:icon="icon"
@click="onClick"
>
<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,7 +34,6 @@ function onSubmit() {
}
// TODO: Else scroll to first error and focus input.
}
</script>
<template>
@ -41,5 +42,4 @@ function onSubmit() {
</form>
</template>
<style lang="scss">
</style>
<style lang="scss"></style>

View file

@ -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(() => {

View file

@ -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" />
<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

@ -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

@ -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

@ -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

@ -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,26 +111,27 @@ 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..."/>
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

@ -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

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

View file

@ -3,9 +3,9 @@ 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
);
}
},
},
});
}

View file

@ -11,7 +11,11 @@ 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

@ -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

@ -2,8 +2,8 @@ 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,5 +1,11 @@
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";
interface NodesStoreState {
@ -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

@ -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

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

View file

@ -1,9 +1,19 @@
<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 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";
@ -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,
{
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>
@ -159,77 +199,88 @@ watch(props, async () => {
:field="NodeSortField.HOSTNAME"
:currentField="sortField"
:currentDirection="sortDirection"
@sort="updateSortOrder">
@sort="updateSortOrder"
>
Name
</sth>
<sth
:field="NodeSortField.NICKNAME"
:currentField="sortField"
:currentDirection="sortDirection"
@sort="updateSortOrder">
@sort="updateSortOrder"
>
Besitzer*in
</sth>
<sth
:field="NodeSortField.EMAIL"
:currentField="sortField"
:currentDirection="sortDirection"
@sort="updateSortOrder">
@sort="updateSortOrder"
>
E-Mail
</sth>
<sth
:field="NodeSortField.TOKEN"
:currentField="sortField"
:currentDirection="sortDirection"
@sort="updateSortOrder">
@sort="updateSortOrder"
>
Token
</sth>
<sth
:field="NodeSortField.MAC"
:currentField="sortField"
:currentDirection="sortDirection"
@sort="updateSortOrder">
@sort="updateSortOrder"
>
MAC
</sth>
<sth
:field="NodeSortField.KEY"
:currentField="sortField"
:currentDirection="sortDirection"
@sort="updateSortOrder">
@sort="updateSortOrder"
>
VPN
</sth>
<sth
:field="NodeSortField.SITE"
:currentField="sortField"
:currentDirection="sortDirection"
@sort="updateSortOrder">
@sort="updateSortOrder"
>
Site
</sth>
<sth
:field="NodeSortField.DOMAIN"
:currentField="sortField"
:currentDirection="sortDirection"
@sort="updateSortOrder">
@sort="updateSortOrder"
>
Domäne
</sth>
<sth
:field="NodeSortField.COORDS"
:currentField="sortField"
:currentDirection="sortDirection"
@sort="updateSortOrder">
@sort="updateSortOrder"
>
GPS
</sth>
<sth
:field="NodeSortField.ONLINE_STATE"
:currentField="sortField"
:currentDirection="sortDirection"
@sort="updateSortOrder">
@sort="updateSortOrder"
>
Status
</sth>
<sth
:field="NodeSortField.MONITORING_STATE"
:currentField="sortField"
:currentDirection="sortDirection"
@sort="updateSortOrder">
@sort="updateSortOrder"
>
Monitoring
</sth>
</tr>
@ -238,47 +289,59 @@ watch(props, async () => {
<tbody>
<tr
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 v-if="shallRedactField(node, 'nickname')">
<span
class="redacted"
@click="setRedactField(node, 'nickname', false)">
@click="setRedactField(node, 'nickname', false)"
>
nickname
</span>
</td>
<td v-if="!shallRedactField(node, 'nickname')">
<span
class="redactable"
@click="setRedactField(node, 'nickname', true)">
@click="setRedactField(node, 'nickname', true)"
>
{{ node.nickname }}
</span>
</td>
<td v-if="shallRedactField(node, 'email')">
<span
class="redacted"
@click="setRedactField(node, 'email', false)">
@click="setRedactField(node, 'email', false)"
>
email@example.com
</span>
</td>
<td v-if="!shallRedactField(node, 'email')">
<span
class="redactable"
@click="setRedactField(node, 'email', true)">
@click="setRedactField(node, 'email', true)"
>
{{ node.email }}
</span>
</td>
<td v-if="shallRedactField(node, 'token')">
<span
class="redacted"
@click="setRedactField(node, 'token', false)">
@click="setRedactField(node, 'token', false)"
>
0123456789abcdef
</span>
</td>
<td v-if="!shallRedactField(node, 'token')">
<span
class="redactable"
@click="setRedactField(node, 'token', true)">
@click="setRedactField(node, 'token', true)"
>
{{ node.token }}
</span>
</td>
@ -288,12 +351,14 @@ watch(props, async () => {
v-if="node.key"
class="fa fa-lock"
aria-hidden="true"
title="Hat VPN-Schlüssel"/>
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"/>
title="Hat keinen VPN-Schlüssel"
/>
</td>
<td>{{ node.site }}</td>
<td>{{ node.domain }}</td>
@ -302,42 +367,50 @@ watch(props, async () => {
v-if="node.coords"
class="fa fa-map-marker"
aria-hidden="true"
title="Hat Koordinaten"/>
title="Hat Koordinaten"
/>
<i
v-if="!node.coords"
class="fa fa-times not-available"
aria-hidden="true"
title="Hat keinen Koordinaten"/>
title="Hat keinen Koordinaten"
/>
</td>
<td v-if="node.onlineState !== undefined">
{{ node.onlineState.toLowerCase() }}
</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"/>
title="Monitoring aktiv"
/>
<i
v-if="node.monitoring && !node.monitoringConfirmed"
class="fa fa-envelope"
aria-hidden="true"
title="Monitoring nicht bestätigt"/>
title="Monitoring nicht bestätigt"
/>
<i
v-if="!node.monitoring"
class="fa fa-times not-available"
aria-hidden="true"
title="Monitoring deaktiviert"/>
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

@ -4,36 +4,45 @@ 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 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

@ -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"/>
<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>