Browse Source

Add search / filter box (#2170)

* feat: Add search client based on #1978

* moved the filtering to the DB level using zod and tidied up some imports.

* minor fix

* minor fix

* fix typo

---------

Co-authored-by: Bernd Storath <[email protected]>
pull/2223/head
YuWorm 8 months ago
committed by GitHub
parent
commit
2b42b639ea
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 38
      src/app/components/Clients/Search.vue
  2. 2
      src/app/components/Panel/head/Boat.vue
  3. 1
      src/app/pages/index.vue
  4. 14
      src/app/stores/clients.ts
  5. 3
      src/i18n/locales/en.json
  6. 3
      src/i18n/locales/zh-CN.json
  7. 3
      src/i18n/locales/zh-HK.json
  8. 1
      src/nuxt.config.ts
  9. 2
      src/package.json
  10. 54
      src/pnpm-lock.yaml
  11. 21
      src/server/api/client/index.get.ts
  12. 70
      src/server/database/repositories/client/service.ts
  13. 8
      src/server/database/repositories/client/types.ts
  14. 20
      src/server/utils/WireGuard.ts

38
src/app/components/Clients/Search.vue

@ -0,0 +1,38 @@
<template>
<div class="relative w-60 md:mr-2">
<div class="relative flex h-full items-center">
<MagnifyingGlassIcon
class="absolute left-2.5 h-4 w-4 text-gray-400 dark:text-neutral-500"
/>
<input
v-model="searchQuery"
type="text"
:placeholder="$t('client.search')"
class="w-full rounded bg-white py-2 pr-8 text-sm text-gray-900 shadow-sm ring-1 ring-gray-300 transition-all placeholder:text-gray-400 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-red-600 dark:bg-neutral-800 dark:text-white dark:ring-neutral-700 dark:placeholder:text-neutral-500 dark:focus:ring-red-700"
@input="updateSearch"
/>
<button
v-if="searchQuery"
class="absolute right-2 flex h-5 w-5 items-center justify-center rounded-full bg-gray-200 text-gray-600 hover:bg-gray-300 hover:text-gray-800 dark:bg-neutral-700 dark:text-neutral-300 dark:hover:bg-neutral-600 dark:hover:text-neutral-100"
aria-label="Clear search"
@click="clearSearch"
>
<IconsClose class="h-3 w-3" />
</button>
</div>
</div>
</template>
<script setup lang="ts">
const clientsStore = useClientsStore();
const searchQuery = ref('');
const updateSearch = useDebounceFn(() => {
clientsStore.setSearchQuery(searchQuery.value);
}, 300);
function clearSearch() {
searchQuery.value = '';
clientsStore.setSearchQuery('');
}
</script>

2
src/app/components/Panel/head/Boat.vue

@ -1,5 +1,5 @@
<template>
<div class="flex flex-shrink-0 space-x-1 md:block">
<div class="flex flex-shrink-0 items-center space-x-2">
<slot />
</div>
</template>

1
src/app/pages/index.vue

@ -4,6 +4,7 @@
<PanelHead>
<PanelHeadTitle :text="$t('pages.clients')" />
<PanelHeadBoat>
<ClientsSearch />
<ClientsSort />
<ClientsNew />
</PanelHeadBoat>

14
src/app/stores/clients.ts

@ -31,8 +31,13 @@ export const useClientsStore = defineStore('Clients', () => {
const clients = ref<null | LocalClient[]>(null);
const clientsPersist = ref<Record<string, ClientPersist>>({});
const searchParams = ref({
filter: undefined as string | undefined,
});
const { data: _clients, refresh: _refresh } = useFetch('/api/client', {
method: 'get',
params: searchParams,
});
// TODO: rewrite
@ -120,6 +125,7 @@ export const useClientsStore = defineStore('Clients', () => {
};
});
// TODO: move sort to backend
if (transformedClients !== undefined) {
transformedClients = sortByProperty(
transformedClients,
@ -130,5 +136,11 @@ export const useClientsStore = defineStore('Clients', () => {
clients.value = transformedClients ?? null;
}
return { clients, clientsPersist, refresh, _clients };
function setSearchQuery(filter: string) {
clients.value = null;
searchParams.value.filter = filter || undefined;
}
return { clients, clientsPersist, refresh, _clients, setSearchQuery };
});

3
src/i18n/locales/en.json

@ -116,7 +116,8 @@
"dnsDesc": "DNS server clients will use (overrides global config)",
"notConnected": "Client not connected",
"endpoint": "Endpoint",
"endpointDesc": "IP of the client from which the WireGuard connection is established"
"endpointDesc": "IP of the client from which the WireGuard connection is established",
"search": "Search clients..."
},
"dialog": {
"change": "Change",

3
src/i18n/locales/zh-CN.json

@ -112,7 +112,8 @@
"persistentKeepaliveDesc": "设置保活数据包的发送间隔(秒)。0表示禁用",
"hooks": "钩子脚本",
"hooksDescription": "钩子脚本仅在使用wg-quick时有效",
"hooksLeaveEmpty": "如果不使用wg-quick,请留空此字段"
"hooksLeaveEmpty": "如果不使用wg-quick,请留空此字段",
"search": "搜索客户端..."
},
"dialog": {
"change": "确认修改",

3
src/i18n/locales/zh-HK.json

@ -113,7 +113,8 @@
"hooks": "掛鉤",
"hooksDescription": "掛鉤僅適用於wg-quick",
"hooksLeaveEmpty": "僅適用於wg-quick,否則請留空",
"dnsDesc": "客戶端使用的域名系統伺服器(取代全局配置)"
"dnsDesc": "客戶端使用的域名系統伺服器(取代全局配置)",
"search": "搜尋客戶端..."
},
"dialog": {
"change": "更改",

1
src/nuxt.config.ts

@ -13,6 +13,7 @@ export default defineNuxtConfig({
'@pinia/nuxt',
'@eschricht/nuxt-color-mode',
'radix-vue/nuxt',
'@vueuse/nuxt',
'@nuxt/eslint',
],
colorMode: {

2
src/package.json

@ -28,6 +28,8 @@
"@phc/format": "^1.0.0",
"@pinia/nuxt": "^0.11.2",
"@tailwindcss/forms": "^0.5.10",
"@vueuse/core": "^13.9.0",
"@vueuse/nuxt": "^13.9.0",
"apexcharts": "^5.3.5",
"argon2": "^0.44.0",
"cidr-tools": "^11.0.3",

54
src/pnpm-lock.yaml

@ -35,6 +35,12 @@ importers:
'@tailwindcss/forms':
specifier: ^0.5.10
version: 0.5.10([email protected]([email protected])([email protected]))
'@vueuse/core':
specifier: ^13.9.0
version: 13.9.0([email protected]([email protected]))
'@vueuse/nuxt':
specifier: ^13.9.0
version: 13.9.0([email protected])([email protected](@libsql/[email protected])(@parcel/[email protected])(@types/[email protected])(@vue/[email protected])([email protected](@libsql/[email protected])([email protected](@libsql/[email protected])))([email protected](@libsql/[email protected]))([email protected]([email protected]))([email protected])([email protected])([email protected])([email protected])([email protected])([email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))([email protected]([email protected]))([email protected]))([email protected]([email protected]))
apexcharts:
specifier: ^5.3.5
version: 5.3.5
@ -1874,6 +1880,9 @@ packages:
'@types/[email protected]':
resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==}
'@types/[email protected]':
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
'@types/[email protected]':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
@ -2155,12 +2164,31 @@ packages:
'@vueuse/[email protected]':
resolution: {integrity: sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==}
'@vueuse/[email protected]':
resolution: {integrity: sha512-ts3regBQyURfCE2BcytLqzm8+MmLlo5Ln/KLoxDVcsZ2gzIwVNnQpQOL/UKV8alUqjSZOlpFZcRNsLRqj+OzyA==}
peerDependencies:
vue: ^3.5.0
'@vueuse/[email protected]':
resolution: {integrity: sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==}
'@vueuse/[email protected]':
resolution: {integrity: sha512-1AFRvuiGphfF7yWixZa0KwjYH8ulyjDCC0aFgrGRz8+P4kvDFSdXLVfTk5xAN9wEuD1J6z4/myMoYbnHoX07zg==}
'@vueuse/[email protected]':
resolution: {integrity: sha512-n/9BRU3nLl2mVI6rYbB3jOctCmQD0xT799hXPCwCn1PyvK7r6O9Nt1dxfVCMfKCDAiCi8Fz2IqPC6Zs2Dv1pVA==}
peerDependencies:
nuxt: ^3.0.0 || ^4.0.0-0
vue: ^3.5.0
'@vueuse/[email protected]':
resolution: {integrity: sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==}
'@vueuse/[email protected]':
resolution: {integrity: sha512-e89uuTLMh0U5cZ9iDpEI2senqPGfbPRTHM/0AaQkcxnpqjkZqDYP8rpfm7edOz8s+pOCOROEy1PIveSW8+fL5g==}
peerDependencies:
vue: ^3.5.0
'@yr/[email protected]':
resolution: {integrity: sha512-FQXkOta0XBSUPHndIKON2Y9JeQz5ZeMqLYZVVK93FliNBFm7LNMIZmY6FrMEB9XPcDbE2bekMbZD6kzDkxwYjA==}
@ -7190,6 +7218,8 @@ snapshots:
'@types/[email protected]': {}
'@types/[email protected]': {}
'@types/[email protected]':
dependencies:
'@types/node': 24.7.2
@ -7558,8 +7588,28 @@ snapshots:
- '@vue/composition-api'
- vue
'@vueuse/[email protected]([email protected]([email protected]))':
dependencies:
'@types/web-bluetooth': 0.0.21
'@vueuse/metadata': 13.9.0
'@vueuse/shared': 13.9.0([email protected]([email protected]))
vue: 3.5.22([email protected])
'@vueuse/[email protected]': {}
'@vueuse/[email protected]': {}
'@vueuse/[email protected]([email protected])([email protected](@libsql/[email protected])(@parcel/[email protected])(@types/[email protected])(@vue/[email protected])([email protected](@libsql/[email protected])([email protected](@libsql/[email protected])))([email protected](@libsql/[email protected]))([email protected]([email protected]))([email protected])([email protected])([email protected])([email protected])([email protected])([email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))([email protected]([email protected]))([email protected]))([email protected]([email protected]))':
dependencies:
'@nuxt/kit': 3.19.3([email protected])
'@vueuse/core': 13.9.0([email protected]([email protected]))
'@vueuse/metadata': 13.9.0
local-pkg: 1.1.2
nuxt: 3.19.3(@libsql/[email protected])(@parcel/[email protected])(@types/[email protected])(@vue/[email protected])([email protected](@libsql/[email protected])([email protected](@libsql/[email protected])))([email protected](@libsql/[email protected]))([email protected]([email protected]))([email protected])([email protected])([email protected])([email protected])([email protected])([email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))([email protected]([email protected]))([email protected])
vue: 3.5.22([email protected])
transitivePeerDependencies:
- magicast
'@vueuse/[email protected]([email protected]([email protected]))':
dependencies:
vue-demi: 0.14.10([email protected]([email protected]))
@ -7567,6 +7617,10 @@ snapshots:
- '@vue/composition-api'
- vue
'@vueuse/[email protected]([email protected]([email protected]))':
dependencies:
vue: 3.5.22([email protected])
'@yr/[email protected]': {}
[email protected]: {}

21
src/server/api/client/index.get.ts

@ -1,6 +1,17 @@
export default definePermissionEventHandler('clients', 'custom', ({ user }) => {
if (user.role === roles.ADMIN) {
return WireGuard.getAllClients();
import { ClientQuerySchema } from '#db/repositories/client/types';
export default definePermissionEventHandler(
'clients',
'custom',
async ({ event, user }) => {
const { filter } = await getValidatedQuery(
event,
validateZod(ClientQuerySchema, event)
);
if (user.role === roles.ADMIN) {
return WireGuard.getAllClients(filter);
}
return WireGuard.getClientsForUser(user.id, filter);
}
return WireGuard.getClientsForUser(user.id);
});
);

70
src/server/database/repositories/client/service.ts

@ -1,4 +1,4 @@
import { eq, sql } from 'drizzle-orm';
import { eq, sql, or, like, and } from 'drizzle-orm';
import { containsCidr, parseCidr } from 'cidr-tools';
import { client } from './schema';
import type {
@ -42,6 +42,39 @@ function createPreparedStatement(db: DBType) {
},
})
.prepare(),
findAllPublicFiltered: db.query.client
.findMany({
where: or(
like(client.name, sql.placeholder('filter')),
like(client.ipv4Address, sql.placeholder('filter')),
like(client.ipv6Address, sql.placeholder('filter'))
),
with: {
oneTimeLink: true,
},
columns: {
privateKey: false,
preSharedKey: false,
},
})
.prepare(),
findByUserIdFiltered: db.query.client
.findMany({
where: and(
eq(client.userId, sql.placeholder('userId')),
or(
like(client.name, sql.placeholder('filter')),
like(client.ipv4Address, sql.placeholder('filter')),
like(client.ipv6Address, sql.placeholder('filter'))
)
),
with: { oneTimeLink: true },
columns: {
privateKey: false,
preSharedKey: false,
},
})
.prepare(),
toggle: db
.update(client)
.set({ enabled: sql.placeholder('enabled') as never as boolean })
@ -96,6 +129,41 @@ export class ClientService {
}));
}
/**
* Get clients based on user ID and filter conditions
*/
async getForUserFiltered(userId: ID, filter: string) {
const filterPattern = `%${filter.toLowerCase()}%`;
const result = await this.#statements.findByUserIdFiltered.execute({
userId,
filter: filterPattern,
});
return result.map((row) => ({
...row,
createdAt: new Date(row.createdAt),
updatedAt: new Date(row.updatedAt),
}));
}
/**
* Get all clients based on filter conditions without sensitive data
*/
async getAllPublicFiltered(filter: string) {
const filterPattern = `%${filter.toLowerCase()}%`;
const result = await this.#statements.findAllPublicFiltered.execute({
filter: filterPattern,
});
return result.map((row) => ({
...row,
createdAt: new Date(row.createdAt),
updatedAt: new Date(row.updatedAt),
}));
}
get(id: ID) {
return this.#statements.findById.execute({ id });
}

8
src/server/database/repositories/client/types.ts

@ -39,6 +39,8 @@ const address6 = z
.min(1, { message: t('zod.client.address6') })
.pipe(safeStringRefine);
const filter = z.string().optional();
const serverAllowedIps = z.array(AddressSchema, {
message: t('zod.client.serverAllowedIps'),
});
@ -50,6 +52,12 @@ export const ClientCreateSchema = z.object({
export type ClientCreateType = z.infer<typeof ClientCreateSchema>;
export const ClientQuerySchema = z.object({
filter: filter,
});
export type ClientQueryType = z.infer<typeof ClientQuerySchema>;
export const ClientUpdateSchema = schemaForType<UpdateClientType>()(
z.object({
name: name,

20
src/server/utils/WireGuard.ts

@ -61,10 +61,15 @@ class WireGuard {
WG_DEBUG('Config synced successfully.');
}
async getClientsForUser(userId: ID) {
async getClientsForUser(userId: ID, filter?: string) {
const wgInterface = await Database.interfaces.get();
const dbClients = await Database.clients.getForUser(userId);
let dbClients;
if (filter?.trim()) {
dbClients = await Database.clients.getForUserFiltered(userId, filter);
} else {
dbClients = await Database.clients.getForUser(userId);
}
const clients = dbClients.map((client) => ({
...client,
@ -104,9 +109,16 @@ class WireGuard {
return clientDump;
}
async getAllClients() {
async getAllClients(filter?: string) {
const wgInterface = await Database.interfaces.get();
const dbClients = await Database.clients.getAllPublic();
let dbClients;
if (filter?.trim()) {
dbClients = await Database.clients.getAllPublicFiltered(filter);
} else {
dbClients = await Database.clients.getAllPublic();
}
const clients = dbClients.map((client) => ({
...client,
latestHandshakeAt: null as Date | null,

Loading…
Cancel
Save