Skip to content

Commit 0f455d1

Browse files
committed
feat: Implement a guard and confirmation modal to prevent accidental disconnection when disabling the current access interface.
1 parent c9528be commit 0f455d1

File tree

9 files changed

+379
-16
lines changed

9 files changed

+379
-16
lines changed

landscape-webui/src/api/client.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { getClientCaller } from "@landscape-router/types/api/client/client";
2+
3+
export { getClientCaller as get_client_caller };

landscape-webui/src/components/iface/IfaceChangeZone.vue

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { stop_and_del_iface_nat } from "@/api/service_nat";
1111
import { delete_and_stop_iface_pppd_by_attach_iface_name } from "@/api/service_pppd";
1212
import { ZoneType } from "@/lib/service_ipconfig";
1313
import { IfaceZoneType } from "@landscape-router/types/api/schemas";
14+
import IfaceDisableGuardModal from "@/components/iface/IfaceDisableGuardModal.vue";
1415
import { ref } from "vue";
1516
1617
const showModal = defineModel<boolean>("show", { required: true });
@@ -23,20 +24,31 @@ const iface_info = defineProps<{
2324
2425
const spin = ref(false);
2526
const temp_zone = ref(iface_info.zone);
27+
const disable_guard_modal = ref<InstanceType<
28+
typeof IfaceDisableGuardModal
29+
> | null>(null);
2630
2731
async function chageIfaceZone() {
28-
spin.value = true;
29-
try {
30-
await change_zone({
31-
iface_name: iface_info.iface_name,
32-
zone: temp_zone.value,
33-
});
34-
// TODO 调用 拓扑刷新
35-
emit("refresh");
36-
showModal.value = false;
37-
} catch (error) {
38-
} finally {
39-
spin.value = false;
32+
const action = async () => {
33+
spin.value = true;
34+
try {
35+
await change_zone({
36+
iface_name: iface_info.iface_name,
37+
zone: temp_zone.value,
38+
});
39+
// TODO 调用 拓扑刷新
40+
emit("refresh");
41+
showModal.value = false;
42+
} catch (error) {
43+
} finally {
44+
spin.value = false;
45+
}
46+
};
47+
48+
if (disable_guard_modal.value) {
49+
await disable_guard_modal.value.check_and_execute(action);
50+
} else {
51+
await action();
4052
}
4153
}
4254
@@ -83,4 +95,10 @@ function reflush_zone() {
8395
</n-card>
8496
</n-spin>
8597
</n-modal>
98+
99+
<IfaceDisableGuardModal
100+
ref="disable_guard_modal"
101+
:iface_name="iface_name"
102+
@refresh="emit('refresh')"
103+
/>
86104
</template>
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
<script setup lang="ts">
2+
import { computed, ref, watch } from "vue";
3+
import { useI18n } from "vue-i18n";
4+
5+
import type { CallerIdentityResponse } from "@landscape-router/types/api/schemas";
6+
7+
const props = defineProps<{
8+
show: boolean;
9+
ifaceName: string;
10+
callerInfo: CallerIdentityResponse | null;
11+
loading?: boolean;
12+
}>();
13+
14+
const emit = defineEmits<{
15+
(e: "update:show", value: boolean): void;
16+
(e: "confirm"): void;
17+
}>();
18+
19+
const { t } = useI18n();
20+
const confirmText = ref("");
21+
22+
const inputMatched = computed(() => confirmText.value === props.ifaceName);
23+
24+
watch(
25+
() => props.show,
26+
(show) => {
27+
if (!show) {
28+
confirmText.value = "";
29+
}
30+
},
31+
);
32+
33+
function handleClose() {
34+
emit("update:show", false);
35+
}
36+
37+
function handleConfirm() {
38+
if (!inputMatched.value) {
39+
return;
40+
}
41+
42+
emit("confirm");
43+
}
44+
</script>
45+
46+
<template>
47+
<n-modal
48+
:show="show"
49+
preset="card"
50+
style="width: min(92vw, 560px)"
51+
:title="t('misc.iface_disable_guard.title')"
52+
:mask-closable="!loading"
53+
:closable="!loading"
54+
@update:show="emit('update:show', $event)"
55+
>
56+
<n-flex vertical :size="14">
57+
<n-alert type="warning" :show-icon="true">
58+
{{ t("misc.iface_disable_guard.warning") }}
59+
</n-alert>
60+
61+
<n-flex vertical :size="6">
62+
<n-text>
63+
{{
64+
t("misc.iface_disable_guard.current_iface", { iface: ifaceName })
65+
}}
66+
</n-text>
67+
<n-text v-if="callerInfo?.ip" depth="3">
68+
{{ t("misc.iface_disable_guard.current_ip", { ip: callerInfo.ip }) }}
69+
</n-text>
70+
<n-text v-if="callerInfo?.source" depth="3">
71+
{{
72+
t("misc.iface_disable_guard.current_source", {
73+
source: callerInfo.source,
74+
})
75+
}}
76+
</n-text>
77+
<n-text v-if="callerInfo?.hostname" depth="3">
78+
{{
79+
t("misc.iface_disable_guard.current_hostname", {
80+
hostname: callerInfo.hostname,
81+
})
82+
}}
83+
</n-text>
84+
</n-flex>
85+
86+
<n-text>
87+
{{ t("misc.iface_disable_guard.input_label", { iface: ifaceName }) }}
88+
</n-text>
89+
<n-input
90+
v-model:value="confirmText"
91+
:placeholder="
92+
t('misc.iface_disable_guard.input_placeholder', { iface: ifaceName })
93+
"
94+
:disabled="loading"
95+
/>
96+
<n-text depth="3">
97+
{{ t("misc.iface_disable_guard.input_hint") }}
98+
</n-text>
99+
</n-flex>
100+
101+
<template #footer>
102+
<n-flex justify="end">
103+
<n-button :disabled="loading" @click="handleClose">
104+
{{ t("common.cancel") }}
105+
</n-button>
106+
<n-button
107+
type="error"
108+
:loading="loading"
109+
:disabled="!inputMatched"
110+
@click="handleConfirm"
111+
>
112+
{{ t("misc.iface_disable_guard.confirm_button") }}
113+
</n-button>
114+
</n-flex>
115+
</template>
116+
</n-modal>
117+
</template>
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
<script setup lang="ts">
2+
import { ref, computed } from "vue";
3+
import { useI18n } from "vue-i18n";
4+
import {
5+
get_iface_disable_risk_caller,
6+
type IfaceDisableRiskCaller,
7+
} from "@/lib/iface_disable_guard";
8+
9+
const props = withDefaults(
10+
defineProps<{
11+
iface_name: string;
12+
title?: string;
13+
warning?: string;
14+
confirm_button_text?: string;
15+
}>(),
16+
{
17+
title: "",
18+
warning: "",
19+
confirm_button_text: "",
20+
},
21+
);
22+
23+
const emit = defineEmits<{
24+
(e: "refresh"): void;
25+
}>();
26+
27+
const { t } = useI18n();
28+
const show = ref(false);
29+
const caller = ref<IfaceDisableRiskCaller | null>(null);
30+
const input_value = ref("");
31+
const loading = ref(false);
32+
const pending_action = ref<(() => Promise<void>) | null>(null);
33+
34+
const is_match = computed(() => {
35+
return input_value.value === props.iface_name;
36+
});
37+
38+
const display_title = computed(
39+
() => props.title || t("misc.iface_risk_guard.title"),
40+
);
41+
const display_warning = computed(
42+
() => props.warning || t("misc.iface_risk_guard.warning"),
43+
);
44+
const display_confirm_button_text = computed(
45+
() => props.confirm_button_text || t("misc.iface_risk_guard.confirm_button"),
46+
);
47+
48+
async function check_and_execute(action: () => Promise<void>) {
49+
loading.value = true;
50+
try {
51+
const caller_info = await get_iface_disable_risk_caller(props.iface_name);
52+
if (!caller_info) {
53+
// No risk, execute immediately
54+
await action();
55+
} else {
56+
// Risk detected, show modal
57+
caller.value = caller_info;
58+
pending_action.value = action;
59+
input_value.value = "";
60+
show.value = true;
61+
}
62+
} finally {
63+
loading.value = false;
64+
}
65+
}
66+
67+
async function handle_confirm() {
68+
if (!pending_action.value) return;
69+
loading.value = true;
70+
try {
71+
await pending_action.value();
72+
show.value = false;
73+
pending_action.value = null;
74+
} finally {
75+
loading.value = false;
76+
}
77+
}
78+
79+
defineExpose({
80+
check_and_execute,
81+
});
82+
</script>
83+
84+
<template>
85+
<n-modal
86+
v-model:show="show"
87+
preset="dialog"
88+
type="warning"
89+
:title="display_title"
90+
>
91+
<template #default>
92+
<n-flex vertical size="small">
93+
<n-alert type="warning" :show-icon="false">
94+
{{ display_warning }}
95+
</n-alert>
96+
<n-text>{{
97+
t("misc.iface_risk_guard.current_iface", {
98+
iface: caller?.iface_name,
99+
})
100+
}}</n-text>
101+
<n-text>{{
102+
t("misc.iface_risk_guard.current_ip", { ip: caller?.ip })
103+
}}</n-text>
104+
<n-text>{{
105+
t("misc.iface_risk_guard.current_source", { source: caller?.source })
106+
}}</n-text>
107+
<n-text v-if="caller?.hostname">{{
108+
t("misc.iface_risk_guard.current_hostname", {
109+
hostname: caller?.hostname,
110+
})
111+
}}</n-text>
112+
113+
<n-text style="margin-top: 8px">{{
114+
t("misc.iface_risk_guard.input_label", { iface: caller?.iface_name })
115+
}}</n-text>
116+
<n-input
117+
v-model:value="input_value"
118+
:placeholder="
119+
t('misc.iface_risk_guard.input_placeholder', {
120+
iface: caller?.iface_name,
121+
})
122+
"
123+
/>
124+
<n-text depth="3" style="font-size: 12px">{{
125+
t("misc.iface_risk_guard.input_hint")
126+
}}</n-text>
127+
</n-flex>
128+
</template>
129+
130+
<template #action>
131+
<n-flex justify="end">
132+
<n-button @click="show = false">{{ t("common.cancel") }}</n-button>
133+
<n-button
134+
type="error"
135+
:disabled="!is_match"
136+
:loading="loading"
137+
@click="handle_confirm"
138+
>
139+
{{ display_confirm_button_text }}
140+
</n-button>
141+
</n-flex>
142+
</template>
143+
</n-modal>
144+
</template>

landscape-webui/src/components/topology/FlowNode.vue

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import WifiServiceEditModal from "@/components/wifi/WifiServiceEditModal.vue";
1717
import DHCPv4ServiceEditModal from "@/components/dhcp_v4/DHCPv4ServiceEditModal.vue";
1818
1919
import IfaceChangeZone from "../iface/IfaceChangeZone.vue";
20+
import IfaceDisableGuardModal from "@/components/iface/IfaceDisableGuardModal.vue";
2021
import { AreaCustom, Power, Link, DotMark, Delete } from "@vicons/carbon";
2122
import { PlugDisconnected20Regular } from "@vicons/fluent";
2223
import { computed, ref, reactive } from "vue";
@@ -56,6 +57,9 @@ const show_zone_change = ref(false);
5657
const show_pppd_drawer = ref(false);
5758
const show_route_lan_drawer = ref(false);
5859
const show_route_wan_drawer = ref(false);
60+
const disable_guard_modal = ref<InstanceType<
61+
typeof IfaceDisableGuardModal
62+
> | null>(null);
5963
6064
const show_cpu_balance_btn = ref(false);
6165
function handleUpdateShow(show: boolean) {
@@ -72,11 +76,19 @@ async function change_dev_status() {
7276
return;
7377
}
7478
if (props.node.dev_status.t == DevStateType.Up) {
75-
await change_iface_status(props.node.name, false);
79+
if (disable_guard_modal.value) {
80+
await disable_guard_modal.value.check_and_execute(async () => {
81+
await change_iface_status(props.node.name, false);
82+
await refresh();
83+
});
84+
} else {
85+
await change_iface_status(props.node.name, false);
86+
await refresh();
87+
}
7688
} else {
7789
await change_iface_status(props.node.name, true);
90+
await refresh();
7891
}
79-
await refresh();
8092
}
8193
8294
async function remove_controller() {
@@ -493,6 +505,11 @@ const show_switch = computed(() => {
493505
:iface_name="node.name"
494506
@refresh="refresh"
495507
/>
508+
<IfaceDisableGuardModal
509+
ref="disable_guard_modal"
510+
:iface_name="node.name"
511+
@refresh="refresh"
512+
/>
496513
</template>
497514

498515
<style scoped>

0 commit comments

Comments
 (0)