Frontend: Added forms to delete nodes.
This commit is contained in:
parent
a0d186da3a
commit
3a2f0799eb
|
@ -78,4 +78,13 @@ input {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fieldset {
|
||||||
|
margin: $fieldset-margin;
|
||||||
|
padding: $fieldset-padding;
|
||||||
|
|
||||||
|
border: $fieldset-border;
|
||||||
|
border-radius: $fieldset-border-radius;
|
||||||
|
|
||||||
|
background-color: $fieldset-background-color;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
99
frontend/src/components/nodes/NodeDeleteConfirmationForm.vue
Normal file
99
frontend/src/components/nodes/NodeDeleteConfirmationForm.vue
Normal file
|
@ -0,0 +1,99 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import ValidationForm from "@/components/form/ValidationForm.vue";
|
||||||
|
import {useNodeStore} from "@/stores/node";
|
||||||
|
import ButtonGroup from "@/components/form/ButtonGroup.vue";
|
||||||
|
import ActionButton from "@/components/form/ActionButton.vue";
|
||||||
|
import NodePreviewCard from "@/components/nodes/NodePreviewCard.vue";
|
||||||
|
import type {Hostname, StoredNode} from "@/types";
|
||||||
|
import {ButtonSize, ComponentAlignment, ComponentVariant} from "@/types";
|
||||||
|
import router, {route, RouteName} from "@/router";
|
||||||
|
import {computed, nextTick, ref} from "vue";
|
||||||
|
import {ApiError} from "@/utils/Api";
|
||||||
|
import ErrorCard from "@/components/ErrorCard.vue";
|
||||||
|
import {useConfigStore} from "@/stores/config";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
node: StoredNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
const nodeStore = useNodeStore();
|
||||||
|
const configStore = useConfigStore();
|
||||||
|
const email = computed(() => configStore.getConfig?.community.contactEmail);
|
||||||
|
|
||||||
|
const errorDeletingNode = ref<boolean>(false);
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "delete", hostname: Hostname): void
|
||||||
|
}>();
|
||||||
|
|
||||||
|
async function onSubmit() {
|
||||||
|
errorDeletingNode.value = false;
|
||||||
|
// Make sure to re-render error message to trigger scrolling into view.
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
const token = props.node.token;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await nodeStore.deleteByToken(token);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ApiError) {
|
||||||
|
// If the node has been deleted in the meantime, we move on as if no error had occured.
|
||||||
|
if (!error.isNotFoundError()) {
|
||||||
|
errorDeletingNode.value = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emit("delete", props.node.hostname);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onAbort() {
|
||||||
|
await router.push(route(RouteName.HOME));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ValidationForm @submit="onSubmit">
|
||||||
|
<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!
|
||||||
|
</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>.
|
||||||
|
</ErrorCard>
|
||||||
|
|
||||||
|
<NodePreviewCard class="preview" :node="props.node"/>
|
||||||
|
|
||||||
|
<ButtonGroup :align="ComponentAlignment.CENTER" :button-size="ButtonSize.SMALL">
|
||||||
|
<ActionButton
|
||||||
|
type="submit"
|
||||||
|
icon="trash"
|
||||||
|
:variant="ComponentVariant.WARNING"
|
||||||
|
:size="ButtonSize.MEDIUM">
|
||||||
|
Knoten löschen
|
||||||
|
</ActionButton>
|
||||||
|
<ActionButton
|
||||||
|
type="reset"
|
||||||
|
icon="times"
|
||||||
|
:variant="ComponentVariant.SECONDARY"
|
||||||
|
:size="ButtonSize.MEDIUM"
|
||||||
|
@click="onAbort">
|
||||||
|
Abbrechen
|
||||||
|
</ActionButton>
|
||||||
|
</ButtonGroup>
|
||||||
|
</ValidationForm>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.preview {
|
||||||
|
margin: 1em 0;
|
||||||
|
}
|
||||||
|
</style>
|
122
frontend/src/components/nodes/NodeDeleteForm.vue
Normal file
122
frontend/src/components/nodes/NodeDeleteForm.vue
Normal file
|
@ -0,0 +1,122 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {useConfigStore} from "@/stores/config";
|
||||||
|
import {useNodeStore} from "@/stores/node";
|
||||||
|
import {computed, nextTick, ref} from "vue";
|
||||||
|
import CONSTRAINTS from "@/shared/validation/constraints";
|
||||||
|
import ActionButton from "@/components/form/ActionButton.vue";
|
||||||
|
import type {StoredNode, Token} from "@/types";
|
||||||
|
import {ButtonSize, ComponentAlignment, ComponentVariant} from "@/types";
|
||||||
|
import ErrorCard from "@/components/ErrorCard.vue";
|
||||||
|
import ButtonGroup from "@/components/form/ButtonGroup.vue";
|
||||||
|
import ValidationForm from "@/components/form/ValidationForm.vue";
|
||||||
|
import ValidationFormInput from "@/components/form/ValidationFormInput.vue";
|
||||||
|
import {route, RouteName} from "@/router";
|
||||||
|
import RouteButton from "@/components/form/RouteButton.vue";
|
||||||
|
import {ApiError} from "@/utils/Api";
|
||||||
|
|
||||||
|
const configStore = useConfigStore();
|
||||||
|
const email = computed(() => configStore.getConfig?.community.contactEmail);
|
||||||
|
|
||||||
|
const nodeStore = useNodeStore();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "submit", node: StoredNode): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const token = ref("");
|
||||||
|
const nodeNotFound = ref<boolean>(false);
|
||||||
|
const generalError = ref<boolean>(false);
|
||||||
|
|
||||||
|
async function onSubmit() {
|
||||||
|
nodeNotFound.value = false;
|
||||||
|
generalError.value = false;
|
||||||
|
// Make sure to re-render error message to trigger scrolling into view.
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const node = await nodeStore.fetchByToken(token.value as Token);
|
||||||
|
emit("submit", node);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ApiError) {
|
||||||
|
if (error.isNotFoundError()) {
|
||||||
|
nodeNotFound.value = true;
|
||||||
|
} else {
|
||||||
|
console.error(error);
|
||||||
|
generalError.value = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ValidationForm novalidate="" ref="form" @submit="onSubmit">
|
||||||
|
<h2>Knoten löschen</h2>
|
||||||
|
|
||||||
|
<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.
|
||||||
|
</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>.
|
||||||
|
</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ErrorCard v-if="nodeNotFound">
|
||||||
|
Zum Token wurde kein passender Knoten gefunden.
|
||||||
|
</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>.
|
||||||
|
</ErrorCard>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<ValidationFormInput
|
||||||
|
v-model="token"
|
||||||
|
label="Token"
|
||||||
|
placeholder="Dein 16-stelliger Token"
|
||||||
|
: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">
|
||||||
|
<ActionButton
|
||||||
|
type="submit"
|
||||||
|
icon="trash"
|
||||||
|
:variant="ComponentVariant.WARNING"
|
||||||
|
:size="ButtonSize.SMALL">
|
||||||
|
Knoten löschen
|
||||||
|
</ActionButton>
|
||||||
|
<RouteButton
|
||||||
|
type="reset"
|
||||||
|
icon="times"
|
||||||
|
:variant="ComponentVariant.SECONDARY"
|
||||||
|
:size="ButtonSize.SMALL"
|
||||||
|
:route="route(RouteName.HOME)">
|
||||||
|
Abbrechen
|
||||||
|
</RouteButton>
|
||||||
|
</ButtonGroup>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<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
|
||||||
|
(Konfigurationsoberfläche des Routers) hinterlegten.
|
||||||
|
</em>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</ValidationForm>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
</style>
|
80
frontend/src/components/nodes/NodeDeletedPanel.vue
Normal file
80
frontend/src/components/nodes/NodeDeletedPanel.vue
Normal file
|
@ -0,0 +1,80 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import ButtonGroup from "@/components/form/ButtonGroup.vue";
|
||||||
|
import type {Hostname} from "@/types";
|
||||||
|
import {ButtonSize, ComponentAlignment, ComponentVariant} from "@/types";
|
||||||
|
import {useConfigStore} from "@/stores/config";
|
||||||
|
import {computed} from "vue";
|
||||||
|
import RouteButton from "@/components/form/RouteButton.vue";
|
||||||
|
import {route, RouteName} from "@/router";
|
||||||
|
|
||||||
|
const configStore = useConfigStore();
|
||||||
|
const email = computed(() => configStore.getConfig?.community.contactEmail);
|
||||||
|
const mapUrl = computed(() => configStore.getConfig?.map.mapUrl);
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
hostname: Hostname;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<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.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="summary">
|
||||||
|
<div class="deleted-node">
|
||||||
|
<i class="fa fa-trash-o" aria-hidden="true"/> <span class="node">{{ 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.
|
||||||
|
</em>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Bei Fragen wende Dich gerne an
|
||||||
|
<a :href="`mailto:${ email }`">{{ email }}</a>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ButtonGroup :align="ComponentAlignment.CENTER" :button-size="ButtonSize.MEDIUM">
|
||||||
|
<RouteButton
|
||||||
|
icon="reply"
|
||||||
|
:variant="ComponentVariant.SECONDARY"
|
||||||
|
:size="ButtonSize.MEDIUM"
|
||||||
|
:route="route(RouteName.HOME)">
|
||||||
|
Zurück zum Anfang
|
||||||
|
</RouteButton>
|
||||||
|
</ButtonGroup>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "../../scss/variables";
|
||||||
|
|
||||||
|
.summary {
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
.deleted-node {
|
||||||
|
display: inline-block;
|
||||||
|
|
||||||
|
padding: $node-deleted-summary-padding;
|
||||||
|
|
||||||
|
border: $node-deleted-summary-border;
|
||||||
|
border-radius: $node-deleted-summary-border-radius;
|
||||||
|
|
||||||
|
font-size: $node-deleted-summary-font-size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
82
frontend/src/components/nodes/NodePreviewCard.vue
Normal file
82
frontend/src/components/nodes/NodePreviewCard.vue
Normal file
|
@ -0,0 +1,82 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type {StoredNode} from "@/types";
|
||||||
|
import {MonitoringState} from "@/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
node: StoredNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="node-preview-card">
|
||||||
|
<h3>
|
||||||
|
<i class="fa fa-dot-circle-o" aria-hidden="true" title="Knotenname:" />
|
||||||
|
{{ node.hostname }}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<strong>Token:</strong> <code>{{ node.token }}</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<i class="fa fa-map-marker" aria-hidden="true" title="Standort:" />
|
||||||
|
{{ node.coords || "nicht angegeben" }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<strong>MAC-Adresse:</strong> <code>{{ node.mac }}</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<strong>VPN-Schlüssel:</strong> <code>{{ node.key || "nicht angegeben" }}</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<strong>Monitoring:</strong>
|
||||||
|
<span v-if="node.monitoringState === MonitoringState.PENDING">
|
||||||
|
Bestätigung ausstehend
|
||||||
|
</span>
|
||||||
|
<span v-if="node.monitoringState === MonitoringState.ACTIVE">
|
||||||
|
aktiv
|
||||||
|
</span>
|
||||||
|
<span v-if="node.monitoringState === MonitoringState.DISABLED">
|
||||||
|
nicht aktiv
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import "../../scss/variables";
|
||||||
|
|
||||||
|
.node-preview-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
padding: $node-preview-card-padding;
|
||||||
|
|
||||||
|
border: $node-preview-card-border;
|
||||||
|
border-radius: $node-preview-card-border-radius;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: $node-preview-card-headline-font-size;
|
||||||
|
margin: $node-preview-card-headline-margin;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
margin: $node-preview-card-field-margin;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-size: $node-preview-card-field-code-font-size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -2,6 +2,7 @@ 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 {isNodesFilter, isNodeSortField, isSortDirection, type SearchTerm} from "@/types";
|
import {isNodesFilter, isNodeSortField, isSortDirection, type SearchTerm} from "@/types";
|
||||||
|
|
||||||
export interface Route {
|
export interface Route {
|
||||||
|
@ -11,6 +12,7 @@ export interface Route {
|
||||||
|
|
||||||
export enum RouteName {
|
export enum RouteName {
|
||||||
HOME = "home",
|
HOME = "home",
|
||||||
|
NODE_DELETE = "node-delete",
|
||||||
ADMIN = "admin",
|
ADMIN = "admin",
|
||||||
ADMIN_NODES = "admin-nodes",
|
ADMIN_NODES = "admin-nodes",
|
||||||
}
|
}
|
||||||
|
@ -30,6 +32,11 @@ const router = createRouter({
|
||||||
name: RouteName.HOME,
|
name: RouteName.HOME,
|
||||||
component: HomeView,
|
component: HomeView,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/node/delete",
|
||||||
|
name: RouteName.NODE_DELETE,
|
||||||
|
component: NodeDeleteView,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/admin",
|
path: "/admin",
|
||||||
name: RouteName.ADMIN,
|
name: RouteName.ADMIN,
|
||||||
|
|
|
@ -79,6 +79,8 @@ $link-hover-color: $variant-color-warning;
|
||||||
|
|
||||||
// Form
|
// Form
|
||||||
$label-font-weight: 600;
|
$label-font-weight: 600;
|
||||||
|
$fieldset-margin: 0;
|
||||||
|
$fieldset-padding: 0.5em 0.75em;
|
||||||
$fieldset-border: 0.1em solid $gray;
|
$fieldset-border: 0.1em solid $gray;
|
||||||
$fieldset-border-radius: 0.5em;
|
$fieldset-border-radius: 0.5em;
|
||||||
$fieldset-background-color: $gray-darker;
|
$fieldset-background-color: $gray-darker;
|
||||||
|
@ -114,6 +116,20 @@ $error-card-font-weight: 600;
|
||||||
$error-card-background-color: lighten($variant-color-danger, 10%);
|
$error-card-background-color: lighten($variant-color-danger, 10%);
|
||||||
$error-card-color: $page-background-color;
|
$error-card-color: $page-background-color;
|
||||||
|
|
||||||
|
// Node preview
|
||||||
|
$node-preview-card-padding: 0.75em 1em;
|
||||||
|
$node-preview-card-border: 0.2em solid $gray;
|
||||||
|
$node-preview-card-border-radius: 1em;
|
||||||
|
$node-preview-card-headline-font-size: 1.5em;
|
||||||
|
$node-preview-card-headline-margin: 0 0 0.5em;
|
||||||
|
$node-preview-card-field-margin: 0.25em 0;
|
||||||
|
$node-preview-card-field-code-font-size: 1.25em;
|
||||||
|
|
||||||
|
// Deleted node summary
|
||||||
|
$node-deleted-summary-padding: $node-preview-card-padding;
|
||||||
|
$node-deleted-summary-border: $node-preview-card-border;
|
||||||
|
$node-deleted-summary-border-radius: $node-preview-card-border-radius;
|
||||||
|
$node-deleted-summary-font-size: 1.25em;
|
||||||
|
|
||||||
// Navigation
|
// Navigation
|
||||||
$nav-bar-background-color: $gray-dark;
|
$nav-bar-background-color: $gray-dark;
|
||||||
|
|
23
frontend/src/stores/node.ts
Normal file
23
frontend/src/stores/node.ts
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import {defineStore} from "pinia";
|
||||||
|
import {isStoredNode, type StoredNode, type Token} from "@/types";
|
||||||
|
import {api} from "@/utils/Api";
|
||||||
|
|
||||||
|
interface NodeStoreState {
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useNodeStore = defineStore({
|
||||||
|
id: "node",
|
||||||
|
state(): NodeStoreState {
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
getters: {},
|
||||||
|
actions: {
|
||||||
|
async fetchByToken(token: Token): Promise<StoredNode> {
|
||||||
|
return await api.get(`node/${token}`, isStoredNode);
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteByToken(token: Token): Promise<void> {
|
||||||
|
await api.delete(`node/${token}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
|
@ -4,6 +4,55 @@ import {parseInteger} from "@/utils/Numbers";
|
||||||
|
|
||||||
type Method = "GET" | "PUT" | "POST" | "DELETE";
|
type Method = "GET" | "PUT" | "POST" | "DELETE";
|
||||||
|
|
||||||
|
enum ApiErrorType {
|
||||||
|
REQUEST_FAILED = "request_failed",
|
||||||
|
UNEXPECTED_RESULT_TYPE = "unexpected_result_type",
|
||||||
|
}
|
||||||
|
|
||||||
|
enum HttpStatusCode {
|
||||||
|
NOT_FOUND = 404,
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
private constructor(
|
||||||
|
message: string,
|
||||||
|
private status: number,
|
||||||
|
private errorType: ApiErrorType,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async requestFailed(
|
||||||
|
method: Method,
|
||||||
|
path: string,
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async unexpectedResultType(
|
||||||
|
method: Method,
|
||||||
|
path: string,
|
||||||
|
response: Response,
|
||||||
|
json: any,
|
||||||
|
): Promise<ApiError> {
|
||||||
|
return new ApiError(
|
||||||
|
`API ${method} request result has unexpected type. ${path} => ${json}`,
|
||||||
|
response.status,
|
||||||
|
ApiErrorType.UNEXPECTED_RESULT_TYPE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
isNotFoundError(): boolean {
|
||||||
|
return this.errorType === ApiErrorType.REQUEST_FAILED && this.status === HttpStatusCode.NOT_FOUND;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface PagedListResult<T> {
|
interface PagedListResult<T> {
|
||||||
entries: T[];
|
entries: T[];
|
||||||
total: number;
|
total: number;
|
||||||
|
@ -47,15 +96,14 @@ class Api {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const body = await response.text();
|
throw await ApiError.requestFailed(method, path, response);
|
||||||
throw new Error(`API ${method} request failed: ${path} => ${response.status} - ${body}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isT) {
|
if (isT) {
|
||||||
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 new Error(`API ${method} request result has wrong type. ${path} => ${json}`);
|
throw await ApiError.unexpectedResultType(method, path, response, json);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -3,7 +3,8 @@ import ActionButton from "@/components/form/ActionButton.vue";
|
||||||
import ButtonGroup from "@/components/form/ButtonGroup.vue";
|
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";
|
||||||
</script>
|
import {route, RouteName} from "@/router";
|
||||||
|
import RouteButton from "@/components/form/RouteButton.vue";</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
|
@ -28,12 +29,13 @@ import {ButtonSize, ComponentAlignment, ComponentVariant} from "@/types";
|
||||||
icon="pencil">
|
icon="pencil">
|
||||||
Knotendaten ändern
|
Knotendaten ändern
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
<ActionButton
|
<RouteButton
|
||||||
:variant="ComponentVariant.WARNING"
|
:variant="ComponentVariant.WARNING"
|
||||||
:size="ButtonSize.LARGE"
|
:size="ButtonSize.LARGE"
|
||||||
icon="trash">
|
icon="trash"
|
||||||
|
:route="route(RouteName.NODE_DELETE)">
|
||||||
Knoten löschen
|
Knoten löschen
|
||||||
</ActionButton>
|
</RouteButton>
|
||||||
</ButtonGroup>
|
</ButtonGroup>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
</template>
|
</template>
|
||||||
|
|
30
frontend/src/views/NodeDeleteView.vue
Normal file
30
frontend/src/views/NodeDeleteView.vue
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import PageContainer from "@/components/page/PageContainer.vue";
|
||||||
|
import NodeDeleteForm from "@/components/nodes/NodeDeleteForm.vue";
|
||||||
|
import NodeDeleteConfirmationForm from "@/components/nodes/NodeDeleteConfirmationForm.vue";
|
||||||
|
import NodeDeletedPanel from "@/components/nodes/NodeDeletedPanel.vue";
|
||||||
|
import {ref} from "vue";
|
||||||
|
import type {Hostname, StoredNode} from "@/types";
|
||||||
|
|
||||||
|
const node = ref<StoredNode | undefined>(undefined);
|
||||||
|
const deletedHostname = ref<Hostname | undefined>(undefined);
|
||||||
|
|
||||||
|
async function onSelectNode(nodeToDelete: StoredNode) {
|
||||||
|
node.value = nodeToDelete;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDelete(hostname: Hostname) {
|
||||||
|
deletedHostname.value = hostname;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PageContainer>
|
||||||
|
<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>
|
|
@ -3,14 +3,14 @@
|
||||||
|
|
||||||
// noinspection RegExpSimplifiable
|
// noinspection RegExpSimplifiable
|
||||||
const CONSTRAINTS = {
|
const CONSTRAINTS = {
|
||||||
id:{
|
id: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
regex: /^[1-9][0-9]*$/,
|
regex: /^[1-9][0-9]*$/,
|
||||||
optional: false
|
optional: false
|
||||||
},
|
},
|
||||||
token:{
|
token: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
regex: /^[0-9a-f]{16}$/i,
|
regex: /^[0-9a-fA-F]{16}$/,
|
||||||
optional: false
|
optional: false
|
||||||
},
|
},
|
||||||
node: {
|
node: {
|
||||||
|
|
|
@ -5,14 +5,14 @@
|
||||||
|
|
||||||
(function () {
|
(function () {
|
||||||
var constraints = {
|
var constraints = {
|
||||||
id:{
|
id: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
regex: /^[1-9][0-9]*$/,
|
regex: /^[1-9][0-9]*$/,
|
||||||
optional: false
|
optional: false
|
||||||
},
|
},
|
||||||
token:{
|
token: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
regex: /^[0-9a-f]{16}$/i,
|
regex: /^[0-9a-fA-F]{16}$/,
|
||||||
optional: false
|
optional: false
|
||||||
},
|
},
|
||||||
node: {
|
node: {
|
||||||
|
|
Loading…
Reference in a new issue