Browse Source

add pagination and sorting to clients list

pull/2433/head
Владислав Прийма 6 months ago
parent
commit
aac3b82fd2
  1. 33
      src/app/components/Clients/Pagination.vue
  2. 6
      src/app/components/Clients/Sort.vue
  3. 5
      src/app/pages/index.vue
  4. 38
      src/app/stores/clients.ts
  5. 3
      src/app/stores/global.ts
  6. 18284
      src/package-lock.json
  7. 8
      src/server/api/client/index.get.ts
  8. 280
      src/server/database/repositories/client/service.ts
  9. 17
      src/server/database/repositories/client/types.ts
  10. 4
      src/server/routes/metrics/json.get.ts
  11. 2
      src/server/routes/metrics/prometheus.get.ts
  12. 58
      src/server/utils/WireGuard.ts

33
src/app/components/Clients/Pagination.vue

@ -0,0 +1,33 @@
<template>
<div
class="mt-4 flex items-center justify-center gap-2"
>
<button
class="btn"
v-if="clientsStore.page > 1"
@click="setPage(clientsStore.page - 1)"
>
</button>
<span class="text-sm text-gray-500">
{{ clientsStore.page }} / {{ clientsStore.totalPages }}
</span>
<button
class="btn"
v-if="clientsStore.page < clientsStore.totalPages"
@click="setPage(clientsStore.page + 1)"
>
</button>
</div>
</template>
<script setup lang="ts">
const clientsStore = useClientsStore();
function setPage(p: number) {
clientsStore.setPageQuery(p);
}
</script>

6
src/app/components/Clients/Sort.vue

@ -1,7 +1,7 @@
<template>
<BasePrimaryButton @click="toggleSort">
<IconsArrowDown
v-if="globalStore.sortClient === true"
v-if="clientsStore.sortClient === true"
class="w-4 md:mr-2"
/>
<IconsArrowUp v-else class="w-4 md:mr-2" />
@ -10,11 +10,9 @@
</template>
<script setup lang="ts">
const globalStore = useGlobalStore();
const clientsStore = useClientsStore();
function toggleSort() {
globalStore.sortClient = !globalStore.sortClient;
clientsStore.refresh().catch(console.error);
clientsStore.setSortClientQuery(!clientsStore.sortClient)
}
</script>

5
src/app/pages/index.vue

@ -15,6 +15,11 @@
v-if="clientsStore.clients && clientsStore.clients.length > 0"
/>
</div>
<div>
<ClientsPagination
v-if="clientsStore.totalPages > 1"
/>
</div>
<ClientsEmpty
v-if="clientsStore.clients && clientsStore.clients.length === 0"
/>

38
src/app/stores/clients.ts

@ -6,7 +6,7 @@ type WGClientReturn = TypedInternalResponse<
'/api/client',
unknown,
'get'
>[number];
>['clients'][number];
export type LocalClient = WGClientReturn & {
avatar?: string;
@ -30,9 +30,16 @@ export const useClientsStore = defineStore('Clients', () => {
const globalStore = useGlobalStore();
const clients = ref<null | LocalClient[]>(null);
const clientsPersist = ref<Record<string, ClientPersist>>({});
const sortClient = ref<boolean>(true)
const page = ref<number>(1);
const limit = ref<number>(10);
const totalPages = ref<number>(1);
const searchParams = ref({
filter: undefined as string | undefined,
page: page,
limit: limit,
sortClient: sortClient
});
const { data: _clients, refresh: _refresh } = useFetch('/api/client', {
@ -43,7 +50,7 @@ export const useClientsStore = defineStore('Clients', () => {
// TODO: rewrite
async function refresh({ updateCharts = false } = {}) {
await _refresh();
let transformedClients = _clients.value?.map((client) => {
let transformedClients = _clients.value?.clients.map((client) => {
let avatar = undefined;
if (client.name.includes('@') && client.name.includes('.')) {
avatar = `https://gravatar.com/avatar/${sha256(client.name.toLowerCase().trim())}.jpg`;
@ -125,16 +132,11 @@ export const useClientsStore = defineStore('Clients', () => {
};
});
// TODO: move sort to backend
if (transformedClients !== undefined) {
transformedClients = sortByProperty(
transformedClients,
'name',
globalStore.sortClient
);
}
clients.value = transformedClients ?? null;
if (_clients.value){
let total = (_clients.value?.total ?? _clients.value?.clients?.length) ?? 0;
totalPages.value = total <= limit.value ? 1 : Math.ceil(total / limit.value);
}
}
function setSearchQuery(filter: string) {
@ -142,5 +144,17 @@ export const useClientsStore = defineStore('Clients', () => {
searchParams.value.filter = filter || undefined;
}
return { clients, clientsPersist, refresh, _clients, setSearchQuery };
function setPageQuery(value: number) {
clients.value = null;
page.value = value;
searchParams.value.page = value;
}
function setSortClientQuery(value: boolean) {
clients.value = null;
sortClient.value = value;
searchParams.value.sortClient = value;
}
return { clients, clientsPersist, sortClient, page, totalPages, refresh, _clients, setSearchQuery, setPageQuery, setSortClientQuery };
});

3
src/app/stores/global.ts

@ -3,8 +3,6 @@ export const useGlobalStore = defineStore('Global', () => {
method: 'get',
});
const sortClient = ref(true); // Sort clients by name, true = asc, false = desc
const uiShowCharts = useCookie<boolean>('uiShowCharts', {
default: () => false,
maxAge: 365 * 24 * 60 * 60,
@ -20,7 +18,6 @@ export const useGlobalStore = defineStore('Global', () => {
});
return {
sortClient,
information,
uiShowCharts,
toggleCharts,

18284
src/package-lock.json

File diff suppressed because it is too large

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

@ -4,14 +4,14 @@ export default definePermissionEventHandler(
'clients',
'custom',
async ({ event, user }) => {
const { filter } = await getValidatedQuery(
const { filter, page, limit, sortClient } = await getValidatedQuery(
event,
validateZod(ClientQuerySchema, event)
);
if (user.role === roles.ADMIN) {
return WireGuard.getAllClients(filter);
return WireGuard.getAllClients({ filter: filter, page: page, limit: limit, sortClient: sortClient });
}
return WireGuard.getClientsForUser(user.id, filter);
return WireGuard.getClientsForUser(user.id, { filter: filter, page: page, limit: limit, sortClient: sortClient });
}
);

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

@ -9,13 +9,14 @@ import type {
import type { DBType } from '#db/sqlite';
import { wgInterface, userConfig } from '#db/schema';
function createPreparedStatement(db: DBType) {
function createPreparedStatement(db: DBType, direction: 'ASC' | 'DESC') {
return {
findAll: db.query.client
.findMany({
with: {
oneTimeLink: true,
},
orderBy: sql`${client.name} ${sql.raw(direction)}`,
})
.prepare(),
findAllPublic: db.query.client
@ -27,6 +28,27 @@ function createPreparedStatement(db: DBType) {
privateKey: false,
preSharedKey: false,
},
orderBy: sql`${client.name} ${sql.raw(direction)}`,
})
.prepare(),
countAll: db
.select({
total: sql<number>`count(*)`
})
.from(client)
.prepare(),
findPaginatedListPublic: db.query.client
.findMany({
with: {
oneTimeLink: true,
},
columns: {
privateKey: false,
preSharedKey: false,
},
limit: sql.placeholder('limit'),
offset: sql.placeholder('offset'),
orderBy: sql`${client.name} ${sql.raw(direction)}`,
})
.prepare(),
findById: db.query.client
@ -40,6 +62,31 @@ function createPreparedStatement(db: DBType) {
privateKey: false,
preSharedKey: false,
},
orderBy: sql`${client.name} ${sql.raw(direction)}`,
})
.prepare(),
countByUserId: db
.select({
total: sql<number>`count(*)`
})
.from(client)
.where(
and(
eq(client.userId, sql.placeholder('userId'))
)
)
.prepare(),
findPaginatedListByUserId: db.query.client
.findMany({
where: eq(client.userId, sql.placeholder('userId')),
with: { oneTimeLink: true },
columns: {
privateKey: false,
preSharedKey: false,
},
limit: sql.placeholder('limit'),
offset: sql.placeholder('offset'),
orderBy: sql`${client.name} ${sql.raw(direction)}`,
})
.prepare(),
findAllPublicFiltered: db.query.client
@ -56,6 +103,41 @@ function createPreparedStatement(db: DBType) {
privateKey: false,
preSharedKey: false,
},
orderBy: sql`${client.name} ${sql.raw(direction)}`,
})
.prepare(),
countAllFiltered: db
.select({
total: sql<number>`count(*)`
})
.from(client)
.where(
and(
or(
like(client.name, sql.placeholder('filter')),
like(client.ipv4Address, sql.placeholder('filter')),
like(client.ipv6Address, sql.placeholder('filter'))
)
)
)
.prepare(),
findPaginatedListPublicFiltered: 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,
},
limit: sql.placeholder('limit'),
offset: sql.placeholder('offset'),
orderBy: sql`${client.name} ${sql.raw(direction)}`,
})
.prepare(),
findByUserIdFiltered: db.query.client
@ -73,6 +155,43 @@ function createPreparedStatement(db: DBType) {
privateKey: false,
preSharedKey: false,
},
orderBy: sql`${client.name} ${sql.raw(direction)}`,
})
.prepare(),
countByUserIdFiltered: db
.select({
total: sql<number>`count(*)`
})
.from(client)
.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'))
)
)
)
.prepare(),
findPaginatedListByUserIdFiltered: 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,
},
limit: sql.placeholder('limit'),
offset: sql.placeholder('offset'),
orderBy: sql`${client.name} ${sql.raw(direction)}`,
})
.prepare(),
toggle: db
@ -89,15 +208,39 @@ function createPreparedStatement(db: DBType) {
export class ClientService {
#db: DBType;
#statements: ReturnType<typeof createPreparedStatement>;
constructor(db: DBType) {
this.#db = db;
this.#statements = createPreparedStatement(db);
}
async getForUser(userId: ID) {
const result = await this.#statements.findByUserId.execute({ userId });
getStatements(direction?: 'ASC' | 'DESC'){
return createPreparedStatement(this.#db, direction ?? "ASC");
}
async getForUser(userId: ID, sortClient: boolean) {
const direction = sortClient ? 'ASC' : 'DESC';
const result = await this.getStatements(direction).findByUserId.execute({ userId });
return result.map((row) => ({
...row,
createdAt: new Date(row.createdAt),
updatedAt: new Date(row.updatedAt),
}));
}
async countForUser(userId: ID) {
const result = await this.getStatements().countByUserId.execute({ userId });
return result?.at(0)?.total ?? 0;
}
async getPaginatedListForUser(userId: ID, page: number, limit: number, sortClient: boolean) {
const direction = sortClient ? 'ASC' : 'DESC';
const offset = (page - 1) * limit;
const result = await this.getStatements(direction).findPaginatedListByUserId.execute({
userId,
limit,
offset });
return result.map((row) => ({
...row,
createdAt: new Date(row.createdAt),
@ -108,8 +251,9 @@ export class ClientService {
/**
* Never return values directly from this function. Use {@link getAllPublic} instead.
*/
async getAll() {
const result = await this.#statements.findAll.execute();
async getAll(sortClient: boolean) {
const direction = sortClient ? 'ASC' : 'DESC';
const result = await this.getStatements(direction).findAll.execute();
return result.map((row) => ({
...row,
createdAt: new Date(row.createdAt),
@ -120,8 +264,38 @@ export class ClientService {
/**
* Returns all clients without sensitive data
*/
async getAllPublic() {
const result = await this.#statements.findAllPublic.execute();
async getAllPublic(sortClient: boolean) {
const direction = sortClient ? 'ASC' : 'DESC';
const result = await this.getStatements(direction).findAllPublic.execute();
return result.map((row) => ({
...row,
createdAt: new Date(row.createdAt),
updatedAt: new Date(row.updatedAt),
}));
}
/**
* Returns count all clients
*/
async countAll() {
const result = await this.getStatements().countAll.execute();
return result?.at(0)?.total ?? 0;
}
/**
* Get a paginated list of clients without sensitive data
*/
async getPaginatedListPublic(page: number, limit: number, sortClient: boolean) {
const direction = sortClient ? 'ASC' : 'DESC';
const offset = (page - 1) * limit;
const result = await this.getStatements(direction).findPaginatedListPublic.execute({
limit,
offset,
});
return result.map((row) => ({
...row,
createdAt: new Date(row.createdAt),
@ -132,10 +306,11 @@ export class ClientService {
/**
* Get clients based on user ID and filter conditions
*/
async getForUserFiltered(userId: ID, filter: string) {
async getForUserFiltered(userId: ID, filter: string, sortClient: boolean) {
const direction = sortClient ? 'ASC' : 'DESC';
const filterPattern = `%${filter.toLowerCase()}%`;
const result = await this.#statements.findByUserIdFiltered.execute({
const result = await this.getStatements(direction).findByUserIdFiltered.execute({
userId,
filter: filterPattern,
});
@ -147,14 +322,87 @@ export class ClientService {
}));
}
/**
* Get count clients based on user ID and filter conditions
*/
async countForUserFiltered(userId: ID, filter: string) {
const filterPattern = `%${filter.toLowerCase()}%`;
const result = await this.getStatements().countByUserIdFiltered.execute({
userId,
filter: filterPattern,
});
return result?.at(0)?.total ?? 0;
}
/**
* Get a paginated list of clients based on user ID and filter conditions
*/
async getPaginatedListForUserFiltered(userId: ID, filter: string, page: number, limit: number, sortClient: boolean) {
const direction = sortClient ? 'ASC' : 'DESC';
const filterPattern = `%${filter.toLowerCase()}%`;
const offset = (page - 1) * limit;
const result = await this.getStatements(direction).findPaginatedListByUserIdFiltered.execute({
userId,
filter: filterPattern,
limit,
offset
});
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) {
async getAllPublicFiltered(filter: string, sortClient: boolean) {
const direction = sortClient ? 'ASC' : 'DESC';
const filterPattern = `%${filter.toLowerCase()}%`;
const result = await this.#statements.findAllPublicFiltered.execute({
const result = await this.getStatements(direction).findAllPublicFiltered.execute({
filter: filterPattern,
});
return result.map((row) => ({
...row,
createdAt: new Date(row.createdAt),
updatedAt: new Date(row.updatedAt),
}));
}
/**
* Get count all clients based on filter conditions
*/
async countAllFiltered(filter: string) {
const filterPattern = `%${filter.toLowerCase()}%`;
const result = await this.getStatements().countAllFiltered.execute({
filter: filterPattern,
});
return result?.at(0)?.total ?? 0;
}
/**
* Get a paginated list of clients based on filter conditions without sensitive data.
*/
async getPaginatedListPublicFiltered(filter: string, page: number, limit: number, sortClient: boolean) {
const direction = sortClient ? 'ASC' : 'DESC';
const filterPattern = `%${filter.toLowerCase()}%`;
const offset = (page - 1) * limit;
const result = await this.getStatements(direction).findPaginatedListPublicFiltered.execute({
filter: filterPattern,
limit,
offset,
sortClient
});
return result.map((row) => ({
@ -165,7 +413,7 @@ export class ClientService {
}
get(id: ID) {
return this.#statements.findById.execute({ id });
return this.getStatements().findById.execute({ id });
}
async create({ name, expiresAt }: ClientCreateType) {
@ -232,11 +480,11 @@ export class ClientService {
}
toggle(id: ID, enabled: boolean) {
return this.#statements.toggle.execute({ id, enabled });
return this.getStatements().toggle.execute({ id, enabled });
}
delete(id: ID) {
return this.#statements.delete.execute({ id });
return this.getStatements().delete.execute({ id });
}
update(id: ID, data: UpdateClientType) {

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

@ -41,6 +41,20 @@ const address6 = z
const filter = z.string().optional();
const page = z.coerce.number().int().optional();
const limit = z.coerce.number().int().optional();
const sortClient = z
.coerce
.string()
.optional()
.transform((value) => {
if (value === "true") return true;
else if (value === "false") return false;
return undefined;
});
const serverAllowedIps = z.array(AddressSchema, {
message: t('zod.client.serverAllowedIps'),
});
@ -54,6 +68,9 @@ export type ClientCreateType = z.infer<typeof ClientCreateSchema>;
export const ClientQuerySchema = z.object({
filter: filter,
page: page,
limit: limit,
sortClient: sortClient,
});
export type ClientQueryType = z.infer<typeof ClientQuerySchema>;

4
src/server/routes/metrics/json.get.ts

@ -7,7 +7,7 @@ async function getMetricsJSON() {
let wireguardPeerCount = 0;
let wireguardEnabledPeersCount = 0;
let wireguardConnectedPeersCount = 0;
for (const client of clients) {
for (const client of clients.clients) {
wireguardPeerCount++;
if (client.enabled === true) {
wireguardEnabledPeersCount++;
@ -20,7 +20,7 @@ async function getMetricsJSON() {
wireguard_configured_peers: wireguardPeerCount,
wireguard_enabled_peers: wireguardEnabledPeersCount,
wireguard_connected_peers: wireguardConnectedPeersCount,
clients: clients.map((client) => ({
clients: clients.clients.map((client) => ({
name: client.name,
enabled: client.enabled,
ipv4Address: client.ipv4Address,

2
src/server/routes/metrics/prometheus.get.ts

@ -12,7 +12,7 @@ async function getPrometheusResponse() {
const wireguardSentBytes = [];
const wireguardReceivedBytes = [];
const wireguardLatestHandshakeSeconds = [];
for (const client of clients) {
for (const client of clients.clients) {
wireguardPeerCount++;
if (client.enabled === true) {
wireguardEnabledPeersCount++;

58
src/server/utils/WireGuard.ts

@ -24,7 +24,7 @@ class WireGuard {
* Make sure to pass an updated InterfaceType object
*/
async #saveWireguardConfig(wgInterface: InterfaceType) {
const clients = await Database.clients.getAll();
const clients = await Database.clients.getAll(true);
const hooks = await Database.hooks.get();
const result = [];
@ -64,14 +64,28 @@ class WireGuard {
WG_DEBUG('Config synced successfully.');
}
async getClientsForUser(userId: ID, filter?: string) {
async getClientsForUser(userId: ID, options?: { filter?: string; page?: number; limit?: number, sortClient?: boolean }) {
const { filter, page, limit } = options || {};
const sortClient = options?.sortClient ?? true;
const wgInterface = await Database.interfaces.get();
let dbClients;
if (filter?.trim()) {
dbClients = await Database.clients.getForUserFiltered(userId, filter);
let total: number = 0;
if (page && limit && filter?.trim()){
dbClients = await Database.clients.getPaginatedListForUserFiltered(userId, filter, page, limit, sortClient);
total = await Database.clients.countForUserFiltered(userId, filter);
}
else if (page && limit){
dbClients = await Database.clients.getPaginatedListForUser(userId, page, limit, sortClient);
total = await Database.clients.countForUser(userId);
}
else if (filter?.trim()) {
dbClients = await Database.clients.getForUserFiltered(userId, filter, sortClient);
total = dbClients.length;
} else {
dbClients = await Database.clients.getForUser(userId);
dbClients = await Database.clients.getForUser(userId, sortClient);
total = dbClients.length;
}
const clients = dbClients.map((client) => ({
@ -98,7 +112,10 @@ class WireGuard {
}
);
return clients;
return {
total: 1,
clients: clients
};
}
async dumpByPublicKey(publicKey: string) {
@ -112,14 +129,28 @@ class WireGuard {
return clientDump;
}
async getAllClients(filter?: string) {
async getAllClients(options?: { filter?: string; page?: number; limit?: number, sortClient?: boolean }) {
const { filter, page, limit } = options || {};
const sortClient = options?.sortClient ?? true;
const wgInterface = await Database.interfaces.get();
let dbClients;
if (filter?.trim()) {
dbClients = await Database.clients.getAllPublicFiltered(filter);
let total: number = 0;
if (page && limit && filter?.trim()){
dbClients = await Database.clients.getPaginatedListPublicFiltered(filter, page, limit, sortClient);
total = await Database.clients.countAllFiltered(filter);
}
else if (page && limit){
dbClients = await Database.clients.getPaginatedListPublic(page, limit, sortClient);
total = await Database.clients.countAll();
}
else if (filter?.trim()) {
dbClients = await Database.clients.getAllPublicFiltered(filter, sortClient);
total = dbClients.length;
} else {
dbClients = await Database.clients.getAllPublic();
dbClients = await Database.clients.getAllPublic(sortClient);
total = dbClients.length;
}
const clients = dbClients.map((client) => ({
@ -146,7 +177,10 @@ class WireGuard {
}
);
return clients;
return {
total: total,
clients: clients
};
}
async getClientConfiguration({ clientId }: { clientId: ID }) {
@ -277,7 +311,7 @@ class WireGuard {
}
async cronJob() {
const clients = await Database.clients.getAll();
const clients = await Database.clients.getAll(true);
let needsSave = false;
// Expires Feature
for (const client of clients) {

Loading…
Cancel
Save