Feat/web (Patchset 4) (#460)
support basic functions in frontend 1. create/del network 2. inspect network running status
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,5 @@
|
||||
# Vue 3 + TypeScript + Vite
|
||||
|
||||
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + Vue + TS</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "easytier-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@primevue/themes": "^4.2.1",
|
||||
"aura": "link:@primevue/themes/aura",
|
||||
"axios": "^1.7.7",
|
||||
"easytier-frontend-lib": "workspace:*",
|
||||
"primevue": "^4.2.1",
|
||||
"tailwindcss-primeui": "^0.3.4",
|
||||
"vue": "^3.5.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.8.6",
|
||||
"@vitejs/plugin-vue": "^5.1.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.47",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^5.4.10",
|
||||
"vite-plugin-singlefile": "^2.0.3",
|
||||
"vue-tsc": "^2.1.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,131 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import { I18nUtils } from 'easytier-frontend-lib'
|
||||
import { onMounted } from 'vue';
|
||||
import Login from './components/Login.vue'
|
||||
import { Button } from 'primevue';
|
||||
import ApiClient from './modules/api';
|
||||
import DeviceList from './components/DeviceList.vue';
|
||||
|
||||
const api = new ApiClient('http://10.147.223.128:11211/api/v1/'); // Replace with actual API URL
|
||||
|
||||
onMounted(async () => {
|
||||
await I18nUtils.loadLanguageAsync('cn')
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<!-- https://flowbite.com/docs/components/sidebar/#sidebar-with-navbar -->
|
||||
|
||||
<template>
|
||||
<div id="root" class="">
|
||||
<nav class="fixed top-0 z-50 w-full bg-white border-b border-gray-200 dark:bg-gray-800 dark:border-gray-700">
|
||||
<div class="px-3 py-3 lg:px-5 lg:pl-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center justify-start rtl:justify-end">
|
||||
<button data-drawer-target="logo-sidebar" data-drawer-toggle="logo-sidebar" aria-controls="logo-sidebar"
|
||||
type="button"
|
||||
class="inline-flex items-center p-2 text-sm text-gray-500 rounded-lg sm:hidden hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-200 dark:text-gray-400 dark:hover:bg-gray-700 dark:focus:ring-gray-600">
|
||||
<span class="sr-only">Open sidebar</span>
|
||||
<i class="pi pi-list" style="font-size: 1.3rem"></i>
|
||||
</button>
|
||||
<a href="https://flowbite.com" class="flex ms-2 md:me-24">
|
||||
<img src="https://flowbite.com/docs/images/logo.svg" class="h-8 me-3" alt="FlowBite Logo" />
|
||||
<span
|
||||
class="self-center text-xl font-semibold sm:text-2xl whitespace-nowrap dark:text-white">EasyTier</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="flex items-center ms-3">
|
||||
<div>
|
||||
<button type="button"
|
||||
class="flex text-sm bg-gray-800 rounded-full focus:ring-4 focus:ring-gray-300 dark:focus:ring-gray-600"
|
||||
aria-expanded="false" data-dropdown-toggle="dropdown-user">
|
||||
<span class="sr-only">Open user menu</span>
|
||||
<img class="w-8 h-8 rounded-full" src="https://flowbite.com/docs/images/people/profile-picture-5.jpg"
|
||||
alt="user photo">
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="z-50 hidden my-4 text-base list-none bg-white divide-y divide-gray-100 rounded shadow dark:bg-gray-700 dark:divide-gray-600"
|
||||
id="dropdown-user">
|
||||
<div class="px-4 py-3" role="none">
|
||||
<p class="text-sm text-gray-900 dark:text-white" role="none">
|
||||
Neil Sims
|
||||
</p>
|
||||
<p class="text-sm font-medium text-gray-900 truncate dark:text-gray-300" role="none">
|
||||
neil.sims@flowbite.com
|
||||
</p>
|
||||
</div>
|
||||
<ul class="py-1" role="none">
|
||||
<li>
|
||||
<a href="#"
|
||||
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600 dark:hover:text-white"
|
||||
role="menuitem">Dashboard</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#"
|
||||
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600 dark:hover:text-white"
|
||||
role="menuitem">Settings</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#"
|
||||
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600 dark:hover:text-white"
|
||||
role="menuitem">Earnings</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#"
|
||||
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600 dark:hover:text-white"
|
||||
role="menuitem">Sign out</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<aside id="logo-sidebar"
|
||||
class="fixed top-0 left-0 z-40 w-64 h-screen pt-20 transition-transform -translate-x-full bg-white border-r border-gray-200 sm:translate-x-0 dark:bg-gray-800 dark:border-gray-700"
|
||||
aria-label="Sidebar">
|
||||
<div class="h-full px-3 pb-4 overflow-y-auto bg-white dark:bg-gray-800">
|
||||
<ul class="space-y-2 font-medium">
|
||||
<li>
|
||||
<Button variant="text" class="w-full justify-start gap-x-3 pl-1.5" severity="contrast">
|
||||
<i class="pi pi-chart-pie" style="font-size: 1.2rem"></i>
|
||||
<span class="mb-0.5">DashBoard</span>
|
||||
</Button>
|
||||
</li>
|
||||
<li>
|
||||
<Button variant="text" class="w-full justify-start gap-x-3 pl-1.5" severity="contrast">
|
||||
<i class="pi pi-server" style="font-size: 1.2rem"></i>
|
||||
<span class="mb-0.5">Devices</span>
|
||||
</Button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="p-4 sm:ml-64">
|
||||
<div class="p-4 border-2 border-gray-200 border-dashed rounded-lg dark:border-gray-700 mt-14">
|
||||
<div class="grid grid-cols-1 gap-4 mb-4">
|
||||
<DeviceList :api="api"></DeviceList>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-4 mb-4">
|
||||
<Login :api="api"></Login>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
button {
|
||||
text-align: left;
|
||||
justify-content: left;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
@@ -0,0 +1,243 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import ApiClient, { ValidateConfigResponse } from '../modules/api';
|
||||
import { Config, Status, NetworkTypes } from 'easytier-frontend-lib'
|
||||
import { Button, Column, DataTable, Drawer, Toolbar, IftaLabel, Select, Dialog, ConfirmPopup, useConfirm } from 'primevue';
|
||||
|
||||
function toHexString(uint64: bigint, padding = 9): string {
|
||||
let hexString = uint64.toString(16);
|
||||
while (hexString.length < padding) {
|
||||
hexString = '0' + hexString;
|
||||
}
|
||||
return hexString;
|
||||
}
|
||||
|
||||
function uint32ToUuid(part1: number, part2: number, part3: number, part4: number): string {
|
||||
// 将两个 uint64 转换为 16 进制字符串
|
||||
const part1Hex = toHexString(BigInt(part1), 8);
|
||||
const part2Hex = toHexString(BigInt(part2), 8);
|
||||
const part3Hex = toHexString(BigInt(part3), 8);
|
||||
const part4Hex = toHexString(BigInt(part4), 8);
|
||||
|
||||
// 构造 UUID 格式字符串
|
||||
const uuid = `${part1Hex.substring(0, 8)}-${part2Hex.substring(0, 4)}-${part2Hex.substring(4, 8)}-${part3Hex.substring(0, 4)}-${part3Hex.substring(4, 8)}${part4Hex.substring(0, 12)}`;
|
||||
|
||||
return uuid;
|
||||
}
|
||||
|
||||
interface UUID {
|
||||
part1: number;
|
||||
part2: number;
|
||||
part3: number;
|
||||
part4: number;
|
||||
}
|
||||
|
||||
function UuidToStr(uuid: UUID): string {
|
||||
return uint32ToUuid(uuid.part1, uuid.part2, uuid.part3, uuid.part4);
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
api: ApiClient,
|
||||
});
|
||||
|
||||
const api = props.api;
|
||||
|
||||
interface DeviceList {
|
||||
hostname: string;
|
||||
public_ip: string;
|
||||
running_network_count: number;
|
||||
report_time: string;
|
||||
easytier_version: string;
|
||||
running_network_instances?: Array<string>;
|
||||
machine_id: string;
|
||||
}
|
||||
|
||||
const selectedDevice = ref<DeviceList | null>(null);
|
||||
const deviceList = ref<Array<DeviceList>>([]);
|
||||
const instanceIdList = computed(() => {
|
||||
let insts = selectedDevice.value?.running_network_instances || [];
|
||||
let options = insts.map((instance: string) => {
|
||||
return { uuid: instance };
|
||||
});
|
||||
console.log("options", options);
|
||||
return options;
|
||||
});
|
||||
const selectedInstanceId = ref<any | null>(null);
|
||||
const curNetworkInfo = ref<NetworkTypes.NetworkInstance | null>(null);
|
||||
|
||||
const loadDevices = async () => {
|
||||
const resp = await api?.list_machines();
|
||||
console.log(resp);
|
||||
let devices: Array<DeviceList> = [];
|
||||
for (const device of (resp || [])) {
|
||||
devices.push({
|
||||
hostname: device.info?.hostname,
|
||||
public_ip: device.client_url,
|
||||
running_network_instances: device.info?.running_network_instances.map((instance: any) => UuidToStr(instance)),
|
||||
running_network_count: device.info?.running_network_instances.length,
|
||||
report_time: device.info?.report_time,
|
||||
easytier_version: device.info?.easytier_version,
|
||||
machine_id: UuidToStr(device.info?.machine_id),
|
||||
});
|
||||
}
|
||||
deviceList.value = devices;
|
||||
console.log(deviceList.value);
|
||||
};
|
||||
|
||||
interface SelectedDevice {
|
||||
machine_id: string;
|
||||
instance_id: string;
|
||||
}
|
||||
|
||||
const checkDeviceSelected = (): SelectedDevice => {
|
||||
let machine_id = selectedDevice.value?.machine_id;
|
||||
let inst_id = selectedInstanceId.value?.uuid;
|
||||
if (machine_id && inst_id) {
|
||||
return { machine_id, instance_id: inst_id };
|
||||
} else {
|
||||
throw new Error("No device selected");
|
||||
}
|
||||
}
|
||||
|
||||
const loadDeviceInfo = async () => {
|
||||
let selectedDevice = checkDeviceSelected();
|
||||
if (!selectedDevice) {
|
||||
return;
|
||||
}
|
||||
|
||||
let ret = await api?.get_network_info(selectedDevice.machine_id, selectedDevice.instance_id);
|
||||
let device_info = ret[selectedDevice.instance_id]
|
||||
|
||||
curNetworkInfo.value = {
|
||||
instance_id: selectedDevice.instance_id,
|
||||
running: device_info.running,
|
||||
error_msg: device_info.error_msg,
|
||||
detail: device_info,
|
||||
} as NetworkTypes.NetworkInstance;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
setInterval(loadDevices, 1000);
|
||||
setInterval(loadDeviceInfo, 1000);
|
||||
});
|
||||
|
||||
const visibleRight = ref(false);
|
||||
|
||||
const showCreateNetworkDialog = ref(false);
|
||||
const newNetworkConfig = ref<NetworkTypes.NetworkConfig>(NetworkTypes.DEFAULT_NETWORK_CONFIG());
|
||||
|
||||
const verifyNetworkConfig = async (): Promise<ValidateConfigResponse | undefined> => {
|
||||
let machine_id = selectedDevice.value?.machine_id;
|
||||
if (!machine_id) {
|
||||
throw new Error("No machine selected");
|
||||
}
|
||||
|
||||
if (!newNetworkConfig.value) {
|
||||
throw new Error("No network config");
|
||||
}
|
||||
|
||||
let ret = await api?.validate_config(machine_id, newNetworkConfig.value);
|
||||
console.log("verifyNetworkConfig", ret);
|
||||
return ret;
|
||||
}
|
||||
|
||||
const createNewNetwork = async () => {
|
||||
let config = await verifyNetworkConfig();
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
|
||||
let machine_id = selectedDevice.value?.machine_id;
|
||||
if (!machine_id) {
|
||||
throw new Error("No machine selected");
|
||||
}
|
||||
|
||||
let ret = await api?.run_network(machine_id, config?.toml_config);
|
||||
console.log("createNewNetwork", ret);
|
||||
showCreateNetworkDialog.value = false;
|
||||
await loadDevices();
|
||||
}
|
||||
|
||||
const confirm = useConfirm();
|
||||
const confirmDeleteNetwork = (event: any) => {
|
||||
confirm.require({
|
||||
target: event.currentTarget,
|
||||
message: 'Do you want to delete this network?',
|
||||
icon: 'pi pi-info-circle',
|
||||
rejectProps: {
|
||||
label: 'Cancel',
|
||||
severity: 'secondary',
|
||||
outlined: true
|
||||
},
|
||||
acceptProps: {
|
||||
label: 'Delete',
|
||||
severity: 'danger'
|
||||
},
|
||||
accept: async () => {
|
||||
const ret = checkDeviceSelected();
|
||||
await api?.delete_network(ret?.machine_id, ret?.instance_id);
|
||||
await loadDevices();
|
||||
},
|
||||
reject: () => {
|
||||
return;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
<template>
|
||||
<ConfirmPopup></ConfirmPopup>
|
||||
<Dialog v-model:visible="showCreateNetworkDialog" modal header="Create New Network" :style="{ width: '55rem' }">
|
||||
<Config :cur-network="newNetworkConfig" @run-network="createNewNetwork"></Config>
|
||||
</Dialog>
|
||||
|
||||
<DataTable :value="deviceList" tableStyle="min-width: 50rem" :metaKeySelection="true" sortField="hostname"
|
||||
:sortOrder="-1">
|
||||
<template #header>
|
||||
<div class="text-xl font-bold">Device List</div>
|
||||
</template>
|
||||
<Column field="hostname" header="Hostname" sortable style="width: 180px"></Column>
|
||||
<Column field="public_ip" header="Public IP" style="width: 150px"></Column>
|
||||
<Column field="running_network_count" header="Running Network Count" sortable style="width: 150px"></Column>
|
||||
<Column field="report_time" header="Report Time" sortable style="width: 150px"></Column>
|
||||
<Column field="easytier_version" header="EasyTier Version" sortable style="width: 150px"></Column>
|
||||
<Column class="w-24 !text-end">
|
||||
<template #body="{ data }">
|
||||
<Button icon="pi pi-search" @click="selectedDevice = data; visibleRight = true" severity="secondary"
|
||||
rounded></Button>
|
||||
</template>
|
||||
</Column>
|
||||
<template #footer>
|
||||
<div class="flex justify-start">
|
||||
<Button icon="pi pi-refresh" label="Reload" severity="info" @click="loadDevices" />
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
|
||||
<Drawer v-model:visible="visibleRight" header="Device Management" position="right" class="w-1/2 min-w-96">
|
||||
<Toolbar>
|
||||
<template #start>
|
||||
<IftaLabel>
|
||||
<Select v-model="selectedInstanceId" :options="instanceIdList" optionLabel="uuid"
|
||||
inputId="dd-inst-id" placeholder="Select Instance" />
|
||||
<label class="mr-3" for="dd-inst-id">Network</label>
|
||||
</IftaLabel>
|
||||
</template>
|
||||
|
||||
<template #end>
|
||||
<div class="gap-x-3 flex">
|
||||
<Button @click="confirmDeleteNetwork($event)" icon="pi pi-minus" severity="danger" label="Delete"
|
||||
iconPos="right" />
|
||||
<Button @click="showCreateNetworkDialog = true" icon="pi pi-plus" label="Create" iconPos="right" />
|
||||
</div>
|
||||
</template>
|
||||
</Toolbar>
|
||||
|
||||
<Status v-bind:cur-network-inst="curNetworkInfo">
|
||||
|
||||
</Status>
|
||||
</Drawer>
|
||||
</template>
|
||||
@@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-center min-h-screen">
|
||||
<Card class="w-full max-w-md p-6">
|
||||
<template #header>
|
||||
<h2 class="text-2xl font-semibold text-center">{{ isRegistering ? 'Register' : 'Login' }}
|
||||
</h2>
|
||||
</template>
|
||||
<template #content>
|
||||
<form v-if="!isRegistering" @submit.prevent="onSubmit" class="space-y-4">
|
||||
<div class="p-field">
|
||||
<label for="username" class="block text-sm font-medium">Username</label>
|
||||
<InputText id="username" v-model="username" required class="w-full" />
|
||||
</div>
|
||||
<div class="p-field">
|
||||
<label for="password" class="block text-sm font-medium">Password</label>
|
||||
<Password id="password" v-model="password" required toggleMask />
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<Button label="Login" type="submit" class="w-full" />
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<Button label="Register" type="button" class="w-full" @click="isRegistering = true"
|
||||
severity="secondary" />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form v-else @submit.prevent="onRegister" class="space-y-4">
|
||||
<div class="p-field">
|
||||
<label for="register-username" class="block text-sm font-medium">Username</label>
|
||||
<InputText id="register-username" v-model="registerUsername" required class="w-full" />
|
||||
</div>
|
||||
<div class="p-field">
|
||||
<label for="register-password" class="block text-sm font-medium">Password</label>
|
||||
<Password id="register-password" v-model="registerPassword" required toggleMask
|
||||
class="w-full" />
|
||||
</div>
|
||||
<div class="p-field">
|
||||
<label for="captcha" class="block text-sm font-medium">Captcha</label>
|
||||
<InputText id="captcha" v-model="captcha" required class="w-full" />
|
||||
<img :src="captchaSrc" alt="Captcha" class="mt-2 mb-2" />
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<Button label="Register" type="submit" class="w-full" />
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<Button label="Back to Login" type="button" class="w-full" @click="isRegistering = false"
|
||||
severity="secondary" />
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { Card, InputText, Password, Button } from 'primevue';
|
||||
import ApiClient from '../modules/api';
|
||||
import { Credential } from '../modules/api';
|
||||
|
||||
const props = defineProps({
|
||||
api: ApiClient,
|
||||
});
|
||||
|
||||
const api = props.api;
|
||||
|
||||
const username = ref('');
|
||||
const password = ref('');
|
||||
const registerUsername = ref('');
|
||||
const registerPassword = ref('');
|
||||
const captcha = ref('');
|
||||
const captchaSrc = computed(() => api?.captcha_url());
|
||||
const isRegistering = ref(false);
|
||||
|
||||
|
||||
const onSubmit = async () => {
|
||||
console.log('Username:', username.value);
|
||||
console.log('Password:', password.value);
|
||||
// Add your login logic here
|
||||
const credential: Credential = { username: username.value, password: password.value, };
|
||||
const ret = await api?.login(credential);
|
||||
alert(ret?.message);
|
||||
};
|
||||
|
||||
const onRegister = () => {
|
||||
console.log('Register Username:', registerUsername.value);
|
||||
console.log('Register Password:', registerPassword.value);
|
||||
console.log('Captcha:', captcha.value);
|
||||
// Add your register logic here
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,24 @@
|
||||
import { createApp } from 'vue'
|
||||
import './style.css'
|
||||
import 'easytier-frontend-lib/style.css'
|
||||
import App from './App.vue'
|
||||
import EasytierFrontendLib from 'easytier-frontend-lib'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import Aura from '@primevue/themes/aura'
|
||||
import ConfirmationService from 'primevue/confirmationservice';
|
||||
|
||||
createApp(App).use(PrimeVue,
|
||||
{
|
||||
theme: {
|
||||
preset: Aura,
|
||||
options: {
|
||||
prefix: 'p',
|
||||
darkModeSelector: 'system',
|
||||
cssLayer: {
|
||||
name: 'primevue',
|
||||
order: 'tailwind-base, primevue, tailwind-utilities'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
).use(ConfirmationService as any).use(EasytierFrontendLib).mount('#app')
|
||||
@@ -0,0 +1,128 @@
|
||||
import axios, { AxiosError, AxiosInstance, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
|
||||
|
||||
export interface ValidateConfigResponse {
|
||||
toml_config: string;
|
||||
}
|
||||
|
||||
// 定义接口返回的数据结构
|
||||
export interface LoginResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface RegisterResponse {
|
||||
message: string;
|
||||
user: any; // 同上
|
||||
}
|
||||
|
||||
// 定义请求体数据结构
|
||||
export interface Credential {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface RegisterData {
|
||||
credential: Credential;
|
||||
captcha: string;
|
||||
}
|
||||
|
||||
class ApiClient {
|
||||
private client: AxiosInstance;
|
||||
|
||||
constructor(baseUrl: string) {
|
||||
this.client = axios.create({
|
||||
baseURL: baseUrl,
|
||||
withCredentials: true, // 如果需要支持跨域携带cookie
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// 添加请求拦截器
|
||||
this.client.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
||||
return config;
|
||||
}, (error: any) => {
|
||||
return Promise.reject(error);
|
||||
});
|
||||
|
||||
// 添加响应拦截器
|
||||
this.client.interceptors.response.use((response: AxiosResponse) => {
|
||||
console.log('Axios Response:', response);
|
||||
return response.data; // 假设服务器返回的数据都在data属性中
|
||||
}, (error: any) => {
|
||||
if (error.response) {
|
||||
// 请求已发出,但是服务器响应的状态码不在2xx范围
|
||||
console.error('Response Error:', error.response.data);
|
||||
} else if (error.request) {
|
||||
// 请求已发出,但是没有收到响应
|
||||
console.error('Request Error:', error.request);
|
||||
} else {
|
||||
// 发生了一些问题导致请求未发出
|
||||
console.error('Error:', error.message);
|
||||
}
|
||||
return Promise.reject(error);
|
||||
});
|
||||
}
|
||||
|
||||
// 登录
|
||||
public async login(data: Credential): Promise<LoginResponse> {
|
||||
try {
|
||||
const response = await this.client.post<any>('/auth/login', data);
|
||||
console.log("login response:", response);
|
||||
return { success: true, message: 'Login success', };
|
||||
} catch (error) {
|
||||
if (error instanceof AxiosError) {
|
||||
if (error.response?.status === 401) {
|
||||
return { success: false, message: 'Invalid username or password', };
|
||||
} else {
|
||||
return { success: false, message: 'Unknown error, status code: ' + error.response?.status, };
|
||||
}
|
||||
}
|
||||
return { success: false, message: 'Unknown error, error: ' + error, };
|
||||
}
|
||||
}
|
||||
|
||||
// 注册
|
||||
public async register(data: RegisterData): Promise<RegisterResponse> {
|
||||
const response = await this.client.post<RegisterResponse>('/auth/register', data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
public async list_session() {
|
||||
const response = await this.client.get('/sessions');
|
||||
return response;
|
||||
}
|
||||
|
||||
public async list_machines(): Promise<Array<any>> {
|
||||
const response = await this.client.get<any, Record<string, Array<any>>>('/machines');
|
||||
return response.machines;
|
||||
}
|
||||
|
||||
public async get_network_info(machine_id: string, inst_id: string): Promise<any> {
|
||||
const response = await this.client.get<any, Record<string, any>>('/machines/' + machine_id + '/networks/info/' + inst_id);
|
||||
return response.info.map;
|
||||
}
|
||||
|
||||
public async validate_config(machine_id: string, config: any): Promise<ValidateConfigResponse> {
|
||||
const response = await this.client.post<any, ValidateConfigResponse>(`/machines/${machine_id}/validate-config`, {
|
||||
config: config,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
public async run_network(machine_id: string, config: string): Promise<undefined> {
|
||||
await this.client.post<string>(`/machines/${machine_id}/networks`, {
|
||||
config: config,
|
||||
});
|
||||
}
|
||||
|
||||
public async delete_network(machine_id: string, inst_id: string): Promise<undefined> {
|
||||
await this.client.delete<string>(`/machines/${machine_id}/networks/${inst_id}`);
|
||||
}
|
||||
|
||||
public captcha_url() {
|
||||
return this.client.defaults.baseURL + 'auth/captcha';
|
||||
}
|
||||
}
|
||||
|
||||
export default ApiClient;
|
||||
@@ -0,0 +1,33 @@
|
||||
@layer tailwind-base, primevue, tailwind-utilities;
|
||||
|
||||
@layer tailwind-base {
|
||||
@tailwind base;
|
||||
}
|
||||
|
||||
@layer tailwind-utilities {
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
}
|
||||
|
||||
.p-password {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.p-password>input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:root {
|
||||
font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
|
||||
font-size: 0.9rem;
|
||||
line-height: 24px;
|
||||
font-weight: 400;
|
||||
|
||||
color: #0f0f0f;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,11 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
'./index.html',
|
||||
'./src/**/*.{vue,js,ts,jsx,tsx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [require('tailwindcss-primeui')],
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { viteSingleFile } from "vite-plugin-singlefile"
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue(), viteSingleFile()],
|
||||
})
|
||||
Reference in New Issue
Block a user