mirror of
https://github.com/Litlyx/litlyx
synced 2025-12-11 00:08:37 +01:00
new selfhosted version
This commit is contained in:
60
dashboard/components/admin/AiChat.vue
Normal file
60
dashboard/components/admin/AiChat.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<script lang="ts" setup>
|
||||
import { TrashIcon } from 'lucide-vue-next';
|
||||
import type { TAiNewChatSchema } from '~/shared/schema/ai/AiNewChatSchema';
|
||||
|
||||
const { data: chats } = useAuthFetch<TAiNewChatSchema[]>('/api/admin/aichats');
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 h-full overflow-y-auto">
|
||||
<Card v-for="chat of chats.toReversed()">
|
||||
<CardHeader>
|
||||
<div class="flex gap-4 justify-center text-muted-foreground">
|
||||
<div class="font-semibold text-white"> {{ chat.title }} </div>
|
||||
<div> {{ chat.status }} </div>
|
||||
<div> {{ new Date(chat.created_at).toLocaleString('it-IT') }} </div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div v-for="e of chat.messages">
|
||||
<div class="flex gap-2 items-center" v-if="e.role === 'user'">
|
||||
<div class="text-white/40 shrink-0">
|
||||
{{ new Date(chat.created_at).toLocaleString('it-IT') }}
|
||||
</div>
|
||||
<div> {{ e.name }}: </div>
|
||||
<div> {{ e.content }} </div>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center"
|
||||
v-else-if="e.role === 'assistant' && e.tool_calls && e.tool_calls.length > 0">
|
||||
<div class="text-white/40 shrink-0">
|
||||
{{ new Date(chat.created_at).toLocaleString('it-IT') }}
|
||||
</div>
|
||||
<div> {{ e.name }}: </div>
|
||||
<div> Function call <span class="font-semibold">{{e.tool_calls.map((e: any) =>
|
||||
e.function.name).join(' ') }} </span></div>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center" v-else-if="e.role === 'assistant' && !e.tool_calls">
|
||||
<div class="text-white/40 shrink-0">
|
||||
{{ new Date(chat.created_at).toLocaleString('it-IT') }}
|
||||
</div>
|
||||
<div> {{ e.name }}: </div>
|
||||
<div> {{ e.content }} </div>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center" v-else-if="e.role === 'tool'">
|
||||
<div class="text-white/40 shrink-0">
|
||||
{{ new Date(chat.created_at).toLocaleString('it-IT') }}
|
||||
</div>
|
||||
<div> TOOL CALL </div>
|
||||
</div>
|
||||
<div v-else>
|
||||
{{ e.role }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,72 +1,143 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
const { data: result, refresh, status } = useAuthFetch<{
|
||||
aggregations: { info: any, advanced: any, chunks: any[] }[],
|
||||
operations: any[]
|
||||
}>('/api/admin/shard/info');
|
||||
|
||||
function getLastModified(e: any) {
|
||||
return new Date(new Date(e.info.lastmod).getTime() + 1000 * 60 * 60).toLocaleString('it-IT')
|
||||
}
|
||||
|
||||
function getKeys(e: any) {
|
||||
return Object.keys(e.info.key);
|
||||
}
|
||||
|
||||
const replSets = ['shard1ReplSet', 'shard2ReplSet', 'shard3ReplSet'];
|
||||
const colors = ['#d0f4de', '#ffadad', '#e4c1f9', '#fcf6bd', '#ff99c8'];
|
||||
const chunkColors = ['#808080', '#dddddd', '#ccaa00'];
|
||||
|
||||
// const collections = computed(() => {
|
||||
|
||||
// if (!result.value) return;
|
||||
|
||||
// const returnData: {
|
||||
// shards: { data: any, stats: any, doc_percent: number, color: string }[],
|
||||
// info: any,
|
||||
// advanced: any
|
||||
// }[] = [];
|
||||
|
||||
// for (const collection of result.value.aggregations) {
|
||||
// const info = collection.info;
|
||||
// const advanced = collection.advanced;
|
||||
|
||||
// const totalDocs = replSets.reduce((a, repl) => {
|
||||
// return a + ((collection.stats.find((e: any) => e.shard === repl)?.count ?? 0));
|
||||
// }, 0);
|
||||
|
||||
// const shards = replSets.map((repl, index) => {
|
||||
// const data = collection.data.find((e: any) => e.shard === repl);
|
||||
// const stats = collection.stats.find((e: any) => e.shard === repl);
|
||||
// const color = colors[index];
|
||||
// if (!data || !stats) return {
|
||||
// data: {
|
||||
// chunkCount: 0,
|
||||
// percent: 0
|
||||
// },
|
||||
// stats: {
|
||||
// count: 0
|
||||
// },
|
||||
// doc_percent: 0,
|
||||
// color
|
||||
// };
|
||||
// const percent = 100 / totalDocs * (stats.count);
|
||||
// return { data, stats, doc_percent: percent, color };
|
||||
// });
|
||||
// returnData.push({ shards, info, advanced });
|
||||
// }
|
||||
|
||||
|
||||
const { data: backendData, pending: backendPending, refresh: refreshBackend } = useFetch<any>(() => `/api/admin/backend`, signHeaders());
|
||||
// return returnData;
|
||||
// });
|
||||
|
||||
const avgDuration = computed(() => {
|
||||
if (!backendData?.value?.durations) return -1;
|
||||
return (backendData.value.durations.durations.reduce((a: any, e: any) => a + parseInt(e[1]), 0) / backendData.value.durations.durations.length);
|
||||
})
|
||||
|
||||
const labels = new Array(650).fill('-');
|
||||
|
||||
const durationsDatasets = computed(() => {
|
||||
if (!backendData?.value?.durations) return [];
|
||||
|
||||
const colors = ['#2200DD', '#CC0022', '#0022CC', '#FF0000', '#00FF00', '#0000FF'];
|
||||
|
||||
const datasets = [];
|
||||
|
||||
const uniqueConsumers: string[] = Array.from(new Set(backendData.value.durations.durations.map((e: any) => e[0])));
|
||||
|
||||
for (let i = 0; i < uniqueConsumers.length; i++) {
|
||||
|
||||
const consumerDurations = backendData.value.durations.durations.filter((e: any) => e[0] == uniqueConsumers[i]);
|
||||
|
||||
datasets.push({
|
||||
points: consumerDurations.map((e: any) => {
|
||||
return 1000 / parseInt(e[1])
|
||||
}),
|
||||
color: colors[i],
|
||||
chartType: 'line',
|
||||
name: uniqueConsumers[i]
|
||||
})
|
||||
function getShardsOrdered(coll: any) {
|
||||
const shards: Record<string, any> = {}
|
||||
for (const replSet of replSets) {
|
||||
shards[replSet] = coll.advanced.shards[replSet] ?? { count: 0, totalSize: 0, totalIndexSize: 0 }
|
||||
shards[replSet] = { ...shards[replSet], chunks: coll.chunks.find((e: any) => e.shard === replSet)?.chunkCount ?? 0 }
|
||||
}
|
||||
|
||||
return datasets;
|
||||
|
||||
})
|
||||
return shards;
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mt-6 h-full">
|
||||
<div class="flex flex-col gap-4 h-full overflow-y-auto">
|
||||
|
||||
<div class="cursor-default flex justify-center w-full">
|
||||
<div v-if="result && result.operations.length > 0" class="flex flex-col gap-2 mt-4">
|
||||
<AdminBackendOperation :operation="op" v-for="op of result.operations"> </AdminBackendOperation>
|
||||
</div>
|
||||
|
||||
<div v-if="backendData" class="flex flex-col mt-8 gap-6 px-20 items-center w-full">
|
||||
|
||||
<div class="flex gap-8">
|
||||
<div> Queue size: {{ backendData.queue?.size || 'ERROR' }} </div>
|
||||
<div> Avg consumer time: {{ avgDuration.toFixed(1) }} ms </div>
|
||||
<div> Avg processed/s: {{ (1000 / avgDuration).toFixed(1) }} </div>
|
||||
</div>
|
||||
|
||||
<div class="w-full">
|
||||
<AdminBackendLineChart :labels="labels" title="Avg Processed/s" :datasets="durationsDatasets">
|
||||
</AdminBackendLineChart>
|
||||
</div>
|
||||
|
||||
<div @click="refreshBackend()"> Refresh </div>
|
||||
<div v-if="result">
|
||||
<div class="flex items-center gap-2">
|
||||
<Button @click="refresh()" size="sm"> Refresh </Button>
|
||||
<Label> Status: {{ status }} </Label>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 mt-4">
|
||||
<Card v-for="coll of result.aggregations" class="gap-2">
|
||||
<CardContent class="flex flex-col gap-8">
|
||||
<div class="flex gap-2 items-center">
|
||||
|
||||
<div v-if="backendPending">
|
||||
Loading...
|
||||
<div :class="{
|
||||
'bg-green-200': !coll.info.noBalance,
|
||||
'bg-red-200': coll.info.noBalance,
|
||||
}" class="rounded-full size-3"></div>
|
||||
|
||||
<div class="w-[15rem]">
|
||||
<div> {{ coll.info._id.split('.')[1].toString() }} </div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<div v-for="k of getKeys(coll)" class="flex items-center">
|
||||
<Icon name="material-symbols:key-vertical" :size="16"></Icon>
|
||||
<div> {{ k }} </div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-[auto_auto_auto]">
|
||||
<div v-for="(value, indexName) in coll.advanced.indexSizes" class="flex items-center gap-2">
|
||||
<div class="w-[5.5rem] text-right"> {{ formatBytes(value, 2) }} </div>
|
||||
<Icon name="material-symbols:key-vertical" :size="16"></Icon>
|
||||
<div> {{ indexName }} </div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator></Separator>
|
||||
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
|
||||
<AdminBackendShardData v-for="(shard, shardName) of getShardsOrdered(coll)"
|
||||
:shardName="(shardName as any)" :count="shard.count" :totalSize="shard.totalSize"
|
||||
:totalIndexSize="shard.totalIndexSize" :chunks="shard.chunks">
|
||||
</AdminBackendShardData>
|
||||
|
||||
<AdminBackendShardData shardName="Total" :count="coll.advanced.count"
|
||||
:totalSize="coll.advanced.totalSize" :totalIndexSize="coll.advanced.totalIndexSize">
|
||||
</AdminBackendShardData>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,31 +1,37 @@
|
||||
<script lang="ts" setup>
|
||||
import { TrashIcon } from 'lucide-vue-next';
|
||||
|
||||
const { data: feedbacks, pending: pendingFeedbacks } = useFetch<any[]>(() => `/api/admin/feedbacks`, signHeaders());
|
||||
|
||||
const { data: feedbacks, refresh } = useAuthFetch('/api/admin/feedbacks');
|
||||
|
||||
async function deleteFeedback(feedback_id: string) {
|
||||
const sure = confirm('Are you sure to delete the feedback ?');
|
||||
if (!sure) return;
|
||||
await useAuthFetch(`/api/admin/feedbacks_delete?id=${feedback_id}`);
|
||||
refresh();
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mt-6 h-full">
|
||||
<div class="flex flex-col gap-4 h-full overflow-y-auto">
|
||||
|
||||
<div
|
||||
class="cursor-default flex justify-center flex-wrap gap-6 mb-[4rem] mt-4 overflow-auto h-full pt-6 pb-[8rem]">
|
||||
|
||||
<div v-if="feedbacks" class="flex flex-col-reverse gap-4 px-20">
|
||||
<div class="flex flex-col text-center outline outline-[1px] outline-lyx-widget-lighter p-4 gap-2"
|
||||
v-for="feedback of feedbacks">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-lyx-text-dark"> {{ feedback.user[0]?.email || 'DELETED USER' }} </div>
|
||||
<div class="text-lyx-text-dark"> {{ feedback.project[0]?.name || 'DELETED PROJECT' }} </div>
|
||||
</div>
|
||||
<Card v-for="feedback of feedbacks?.toReversed()">
|
||||
<CardHeader>
|
||||
<div class="flex gap-4 justify-center text-muted-foreground">
|
||||
<div> {{ feedback.user_id?.email ?? 'USER_DELETED' }} </div>
|
||||
<div> Project: {{ feedback.project_id }} </div>
|
||||
</div>
|
||||
<CardAction>
|
||||
<TrashIcon @click="deleteFeedback((feedback as any)._id.toString())" class="size-5"></TrashIcon>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="whitespace-pre-wrap">
|
||||
{{ feedback.text }}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
<div v-if="pendingFeedbacks"> Loading...</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
</template>
|
||||
@@ -1,271 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import DateService, { type Slice } from '@services/DateService';
|
||||
import type { ChartData, ChartOptions, TooltipModel } from 'chart.js';
|
||||
import { useLineChart, LineChart } from 'vue-chart-3';
|
||||
import * as fns from 'date-fns';
|
||||
|
||||
const props = defineProps<{ pid: string }>();
|
||||
|
||||
const errorData = ref<{ errored: boolean, text: string }>({ errored: false, text: '' })
|
||||
|
||||
function createGradient(startColor: string) {
|
||||
const c = document.createElement('canvas');
|
||||
const ctx = c.getContext("2d");
|
||||
let gradient: any = `${startColor}22`;
|
||||
if (ctx) {
|
||||
gradient = ctx.createLinearGradient(0, 25, 0, 300);
|
||||
gradient.addColorStop(0, `${startColor}99`);
|
||||
gradient.addColorStop(0.35, `${startColor}66`);
|
||||
gradient.addColorStop(1, `${startColor}22`);
|
||||
} else {
|
||||
console.warn('Cannot get context for gradient');
|
||||
}
|
||||
|
||||
return gradient;
|
||||
}
|
||||
|
||||
const chartOptions = ref<ChartOptions<'line'>>({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: (false as any),
|
||||
scales: {
|
||||
y: {
|
||||
ticks: { display: true },
|
||||
grid: {
|
||||
display: true,
|
||||
drawBorder: false,
|
||||
color: '#CCCCCC22',
|
||||
// borderDash: [5, 10]
|
||||
},
|
||||
beginAtZero: true,
|
||||
},
|
||||
x: {
|
||||
ticks: { display: true },
|
||||
stacked: false,
|
||||
offset: false,
|
||||
grid: {
|
||||
display: true,
|
||||
drawBorder: false,
|
||||
color: '#CCCCCC22',
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
title: { display: false },
|
||||
tooltip: { enabled: false }
|
||||
},
|
||||
});
|
||||
|
||||
const chartData = ref<ChartData<'line' | 'bar' | 'bubble'>>({
|
||||
labels: [],
|
||||
datasets: [
|
||||
{
|
||||
label: 'Visits',
|
||||
data: [],
|
||||
backgroundColor: ['#5655d7'],
|
||||
borderColor: '#5655d7',
|
||||
borderWidth: 4,
|
||||
fill: true,
|
||||
tension: 0.35,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 10,
|
||||
hoverBackgroundColor: '#5655d7',
|
||||
hoverBorderColor: 'white',
|
||||
hoverBorderWidth: 2,
|
||||
segment: {
|
||||
borderColor(ctx, options) {
|
||||
const todayIndex = visitsData.data.value?.todayIndex;
|
||||
if (!todayIndex || todayIndex == -1) return '#5655d7';
|
||||
if (ctx.p1DataIndex >= todayIndex) return '#5655d700';
|
||||
return '#5655d7'
|
||||
},
|
||||
borderDash(ctx, options) {
|
||||
const todayIndex = visitsData.data.value?.todayIndex;
|
||||
if (!todayIndex || todayIndex == -1) return undefined;
|
||||
if (ctx.p1DataIndex == todayIndex - 1) return [3, 5];
|
||||
return undefined;
|
||||
},
|
||||
backgroundColor(ctx, options) {
|
||||
const todayIndex = visitsData.data.value?.todayIndex;
|
||||
if (!todayIndex || todayIndex == -1) return createGradient('#5655d7');
|
||||
if (ctx.p1DataIndex >= todayIndex) return '#5655d700';
|
||||
return createGradient('#5655d7');
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Unique visitors',
|
||||
data: [],
|
||||
backgroundColor: ['#4abde8'],
|
||||
borderColor: '#4abde8',
|
||||
borderWidth: 2,
|
||||
hoverBackgroundColor: '#4abde8',
|
||||
hoverBorderColor: '#4abde8',
|
||||
hoverBorderWidth: 2,
|
||||
type: 'bar',
|
||||
// barThickness: 20,
|
||||
borderSkipped: ['bottom'],
|
||||
},
|
||||
{
|
||||
label: 'Events',
|
||||
data: [],
|
||||
backgroundColor: ['#fbbf24'],
|
||||
borderWidth: 2,
|
||||
hoverBackgroundColor: '#fbbf24',
|
||||
hoverBorderColor: '#fbbf24',
|
||||
hoverBorderWidth: 2,
|
||||
type: 'bubble',
|
||||
stack: 'combined',
|
||||
borderColor: ["#fbbf24"]
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { lineChartProps, lineChartRef, update: updateChart } = useLineChart({ chartData: (chartData as any), options: chartOptions });
|
||||
|
||||
const selectedSlice: Slice = 'day'
|
||||
|
||||
const allDatesFull = ref<string[]>([]);
|
||||
|
||||
|
||||
function transformResponse(input: { _id: string, count: number }[]) {
|
||||
const data = input.map(e => e.count);
|
||||
const labels = input.map(e => DateService.getChartLabelFromISO(e._id, new Date().getTimezoneOffset(), selectedSlice));
|
||||
if (input.length > 0) allDatesFull.value = input.map(e => e._id.toString());
|
||||
|
||||
const todayIndex = input.findIndex(e => new Date(e._id).getTime() > (Date.now() - new Date().getTimezoneOffset() * 1000 * 60));
|
||||
|
||||
return { data, labels, todayIndex }
|
||||
}
|
||||
|
||||
function onResponseError(e: any) {
|
||||
let message = e.response._data.message ?? 'Generic error';
|
||||
if (message == 'internal server error') message = 'Please change slice';
|
||||
errorData.value = { errored: true, text: message }
|
||||
}
|
||||
|
||||
function onResponse(e: any) {
|
||||
if (e.response.status != 500) errorData.value = { errored: false, text: '' }
|
||||
}
|
||||
|
||||
|
||||
const headers = computed(() => {
|
||||
return {
|
||||
'x-from': fns.startOfWeek(fns.subWeeks(Date.now(), 1)).toISOString(),
|
||||
'x-to': fns.endOfWeek(fns.subWeeks(Date.now(), 1)).toISOString(),
|
||||
'x-pid': props.pid
|
||||
}
|
||||
});
|
||||
|
||||
const visitsData = useFetch(`/api/timeline/visits?pid=${props.pid}`, {
|
||||
headers: useComputedHeaders({
|
||||
slice: selectedSlice,
|
||||
custom: { ...headers.value },
|
||||
useActivePid: false,
|
||||
useActiveDomain: false
|
||||
}),
|
||||
lazy: true,
|
||||
transform: transformResponse, onResponseError, onResponse
|
||||
});
|
||||
|
||||
const sessionsData = useFetch(`/api/timeline/sessions?pid=${props.pid}`, {
|
||||
headers: useComputedHeaders({
|
||||
slice: selectedSlice,
|
||||
custom: { ...headers.value },
|
||||
useActivePid: false,
|
||||
useActiveDomain: false
|
||||
}), lazy: true,
|
||||
transform: transformResponse, onResponseError, onResponse
|
||||
});
|
||||
|
||||
const eventsData = useFetch(`/api/timeline/events?pid=${props.pid}`, {
|
||||
headers: useComputedHeaders({
|
||||
slice: selectedSlice,
|
||||
custom: { ...headers.value },
|
||||
useActivePid: false,
|
||||
useActiveDomain: false
|
||||
}), lazy: true,
|
||||
transform: transformResponse, onResponseError, onResponse
|
||||
});
|
||||
|
||||
const readyToDisplay = computed(() => !visitsData.pending.value && !eventsData.pending.value && !sessionsData.pending.value);
|
||||
|
||||
watch(readyToDisplay, () => {
|
||||
if (readyToDisplay.value === true) onDataReady();
|
||||
})
|
||||
|
||||
|
||||
function onDataReady() {
|
||||
if (!visitsData.data.value) return;
|
||||
if (!eventsData.data.value) return;
|
||||
if (!sessionsData.data.value) return;
|
||||
|
||||
chartData.value.labels = visitsData.data.value.labels;
|
||||
|
||||
const maxChartY = Math.max(...visitsData.data.value.data, ...sessionsData.data.value.data);
|
||||
const maxEventSize = Math.max(...eventsData.data.value.data)
|
||||
|
||||
chartData.value.datasets[0].data = visitsData.data.value.data;
|
||||
chartData.value.datasets[1].data = sessionsData.data.value.data;
|
||||
|
||||
chartData.value.datasets[2].data = eventsData.data.value.data.map(e => {
|
||||
const rValue = 20 / maxEventSize * e;
|
||||
return { x: 0, y: maxChartY + 20, r: isNaN(rValue) ? 0 : rValue, r2: e }
|
||||
});
|
||||
|
||||
|
||||
chartData.value.datasets[0].backgroundColor = [createGradient('#5655d7')];
|
||||
chartData.value.datasets[1].backgroundColor = [createGradient('#4abde8')];
|
||||
chartData.value.datasets[2].backgroundColor = [createGradient('#fbbf24')];
|
||||
|
||||
|
||||
(chartData.value.datasets[1] as any).borderSkipped = sessionsData.data.value.data.map((e, i) => {
|
||||
const todayIndex = eventsData.data.value?.todayIndex || 0;
|
||||
if (i == todayIndex - 1) return true;
|
||||
return 'bottom';
|
||||
});
|
||||
|
||||
chartData.value.datasets[2].borderColor = eventsData.data.value.data.map((e, i) => {
|
||||
const todayIndex = eventsData.data.value?.todayIndex || 0;
|
||||
if (i == todayIndex - 1) return '#fbbf2400';
|
||||
return '#fbbf24';
|
||||
});
|
||||
|
||||
updateChart();
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<div class="h-[10rem] w-full flex">
|
||||
<div v-if="!readyToDisplay" class="w-full flex justify-center items-center">
|
||||
<i class="fas fa-spinner text-[2rem] text-accent animate-[spin_1s_linear_infinite] duration-500"></i>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-end w-full" v-if="readyToDisplay && !errorData.errored">
|
||||
<LineChart ref="lineChartRef" class="w-full h-full" v-bind="lineChartProps"> </LineChart>
|
||||
</div>
|
||||
|
||||
<div v-if="errorData.errored" class="flex items-center justify-center py-8">
|
||||
{{ errorData.text }}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
#external-tooltip {
|
||||
border-radius: 3px;
|
||||
color: white;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
transform: translate(-50%, 0);
|
||||
transition: all .1s ease;
|
||||
}
|
||||
</style>
|
||||
19
dashboard/components/admin/MultipleProgress.vue
Normal file
19
dashboard/components/admin/MultipleProgress.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
const props = defineProps<{ items: { value: number, color: string }[] }>();
|
||||
|
||||
function getPercent(index: number) {
|
||||
const total = props.items.reduce((a, e) => a + e.value, 0);
|
||||
const percent = 100 / total * props.items[index].value;
|
||||
return Math.ceil(percent);
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex rounded-md overflow-hidden">
|
||||
<div :style="`width: ${getPercent(index)}%; background-color: ${props.items[index].color};`"
|
||||
v-for="(item, index) of props.items">
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
45
dashboard/components/admin/Onboarding.vue
Normal file
45
dashboard/components/admin/Onboarding.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<script lang="ts" setup>
|
||||
const { data: onboarding } = useAuthFetch('/api/admin/onboarding');
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 h-full overflow-y-auto">
|
||||
|
||||
<div class="mt-6 h-full">
|
||||
|
||||
<div class="cursor-default flex flex-wrap gap-6 mb-[4rem] mt-4 h-full pt-6 pb-[8rem]">
|
||||
|
||||
<div v-if="onboarding" class="flex gap-40 px-20">
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="text-lyx-primary"> Anaytics </div>
|
||||
<div class="flex items-center gap-2"
|
||||
v-for="e of onboarding.analytics.sort((a: any, b: any) => b.count - a.count)">
|
||||
<div>{{ e._id }}</div>
|
||||
<div>{{ e.count }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="text-lyx-primary"> Jobs </div>
|
||||
<div class="flex items-center gap-2"
|
||||
v-for="e of onboarding.jobs.sort((a: any, b: any) => b.count - a.count)">
|
||||
<div>{{ e._id }}</div>
|
||||
<div>{{ e.count }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="onboarding" class="flex flex-col gap-8">
|
||||
<AdminOnboardingPieChart :data="onboarding.analytics" title="Analytics">
|
||||
</AdminOnboardingPieChart>
|
||||
<AdminOnboardingPieChart :data="onboarding.jobs" title="Jobs"></AdminOnboardingPieChart>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,45 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
const { data: onboardings, pending: pendingOnboardings } = useFetch<any>(() => `/api/admin/onboardings`, signHeaders());
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mt-6 h-full">
|
||||
|
||||
<div class="cursor-default flex flex-wrap gap-6 mb-[4rem] mt-4 overflow-auto h-full pt-6 pb-[8rem]">
|
||||
|
||||
<div v-if="onboardings" class="flex gap-40 px-20">
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="text-lyx-primary"> Anaytics </div>
|
||||
<div class="flex items-center gap-2"
|
||||
v-for="e of onboardings.analytics.sort((a: any, b: any) => b.count - a.count)">
|
||||
<div>{{ e._id }}</div>
|
||||
<div>{{ e.count }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="text-lyx-primary"> Jobs </div>
|
||||
<div class="flex items-center gap-2"
|
||||
v-for="e of onboardings.jobs.sort((a: any, b: any) => b.count - a.count)">
|
||||
<div>{{ e._id }}</div>
|
||||
<div>{{ e.count }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="onboardings" class="flex flex-col gap-8">
|
||||
<AdminOnboardingPieChart :data="onboardings.analytics" title="Analytics"></AdminOnboardingPieChart>
|
||||
<AdminOnboardingPieChart :data="onboardings.jobs" title="Jobs"></AdminOnboardingPieChart>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div v-if="pendingOnboardings"> Loading...</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -1,204 +1,10 @@
|
||||
<script lang="ts" setup>
|
||||
import type { TAdminProject } from '~/server/api/admin/projects';
|
||||
|
||||
import { PREMIUM_PLAN, getPlanFromId } from '@data/PREMIUM'
|
||||
import { useSelectMenuStyle } from '~/composables/ui/useSelectMenuStyle';
|
||||
|
||||
import { sub, format, isSameDay, type Duration, startOfDay, endOfDay } from 'date-fns'
|
||||
|
||||
|
||||
const page = ref<number>(1);
|
||||
|
||||
const ordersList = [
|
||||
{ label: 'created_at -->', id: '{ "created_at": 1 }' },
|
||||
{ label: 'created_at <--', id: '{ "created_at": -1 }' },
|
||||
|
||||
{ label: 'active -->', id: '{ "last_log_at": 1 }' },
|
||||
{ label: 'active <--', id: '{ "last_log_at": -1 }' },
|
||||
|
||||
{ label: 'visits -->', id: '{ "visits": 1 }' },
|
||||
{ label: 'visits <--', id: '{ "visits": -1 }' },
|
||||
|
||||
{ label: 'events -->', id: '{ "events": 1 }' },
|
||||
{ label: 'events <--', id: '{ "events": -1 }' },
|
||||
|
||||
{ label: 'sessions -->', id: '{ "sessions": 1 }' },
|
||||
{ label: 'sessions <--', id: '{ "sessions": -1 }' },
|
||||
|
||||
{ label: 'usage total -->', id: '{ "limit_total": 1 }' },
|
||||
{ label: 'usage total <--', id: '{ "limit_total": -1 }' },
|
||||
|
||||
{ label: 'usage visits -->', id: '{ "limit_visits": 1 }' },
|
||||
{ label: 'usage visits <--', id: '{ "limit_visits": -1 }' },
|
||||
|
||||
{ label: 'usage events -->', id: '{ "limit_events": 1 }' },
|
||||
{ label: 'usage events <--', id: '{ "limit_events": -1 }' },
|
||||
|
||||
{ label: 'usage ai -->', id: '{ "limit_ai_messages": 1 }' },
|
||||
{ label: 'usage ai <--', id: '{ "limit_ai_messages": -1 }' },
|
||||
|
||||
{ label: 'plan -->', id: '{ "premium_type": 1 }' },
|
||||
{ label: 'plan <--', id: '{ "premium_type": -1 }' },
|
||||
|
||||
]
|
||||
|
||||
const order = ref<string>('{ "created_at": -1 }');
|
||||
|
||||
const limitList = [
|
||||
{ label: '10', id: 10 },
|
||||
{ label: '20', id: 20 },
|
||||
{ label: '50', id: 50 },
|
||||
{ label: '100', id: 100 },
|
||||
]
|
||||
|
||||
const limit = ref<number>(20);
|
||||
|
||||
const filterList = [
|
||||
{ label: 'ALL', id: '{}' },
|
||||
{ label: 'PREMIUM', id: '{ "premium_type": { "$gt": 0, "$lt": 1000 } }' },
|
||||
{ label: 'APPSUMO', id: '{ "premium_type": { "$gt": 6000, "$lt": 7000 } }' },
|
||||
{ label: 'PREMIUM+APPSUMO', id: '{ "premium_type": { "$gt": 0, "$lt": 7000 } }' },
|
||||
]
|
||||
|
||||
|
||||
function isRangeSelected(duration: Duration) {
|
||||
return isSameDay(selected.value.start, sub(new Date(), duration)) && isSameDay(selected.value.end, new Date())
|
||||
}
|
||||
|
||||
function selectRange(duration: Duration) {
|
||||
selected.value = { start: sub(new Date(), duration), end: new Date() }
|
||||
}
|
||||
|
||||
const ranges = [
|
||||
{ label: 'Last 7 days', duration: { days: 7 } },
|
||||
{ label: 'Last 14 days', duration: { days: 14 } },
|
||||
{ label: 'Last 30 days', duration: { days: 30 } },
|
||||
{ label: 'Last 3 months', duration: { months: 3 } },
|
||||
{ label: 'Last 6 months', duration: { months: 6 } },
|
||||
{ label: 'Last year', duration: { years: 1 } }
|
||||
]
|
||||
const selected = ref<{ start: Date, end: Date }>({ start: sub(new Date(), { days: 14 }), end: new Date() })
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
for (const key in PREMIUM_PLAN) {
|
||||
filterList.push({ label: key, id: `{"premium_type": ${(PREMIUM_PLAN as any)[key].ID}}` });
|
||||
}
|
||||
})
|
||||
|
||||
const filter = ref<string>('{}');
|
||||
|
||||
const { data: projectsInfo, pending: pendingProjects } = useFetch<{ count: number, projects: TAdminProject[] }>(
|
||||
() => `/api/admin/projects?page=${page.value - 1}&limit=${limit.value}&sortQuery=${order.value}&filterQuery=${filter.value}&filterFrom=${selected.value.start.toISOString()}&filterTo=${selected.value.end.toISOString()}`,
|
||||
signHeaders()
|
||||
);
|
||||
|
||||
const { data: metrics, pending: pendingMetrics } = useFetch(
|
||||
() => `/api/admin/metrics?filterFrom=${selected.value.start.toISOString()}&filterTo=${selected.value.end.toISOString()}`,
|
||||
signHeaders()
|
||||
);
|
||||
|
||||
const { uiMenu } = useSelectMenuStyle();
|
||||
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mt-6 h-full">
|
||||
|
||||
|
||||
<div class="flex flex-col items-center gap-8">
|
||||
|
||||
<div class="flex items-center gap-10 px-10">
|
||||
<div class="flex gap-2 items-center">
|
||||
<div>Order:</div>
|
||||
<USelectMenu :uiMenu="uiMenu" class="w-[12rem]" placeholder="Order" :options="ordersList"
|
||||
value-attribute="id" option-attribute="label" v-model="order">
|
||||
</USelectMenu>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 items-center">
|
||||
<div>Limit:</div>
|
||||
<USelectMenu :uiMenu="uiMenu" class="w-[12rem]" placeholder="Limit" :options="limitList"
|
||||
value-attribute="id" option-attribute="label" v-model="limit">
|
||||
</USelectMenu>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 items-center">
|
||||
<div>Filter:</div>
|
||||
<USelectMenu :uiMenu="uiMenu" class="w-[12rem]" placeholder="Filter" :options="filterList"
|
||||
value-attribute="id" option-attribute="label" v-model="filter">
|
||||
</USelectMenu>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-10 justify-center px-10 w-full">
|
||||
|
||||
<div class="flex gap-2 items-center shrink-0">
|
||||
<div>Page {{ page }} </div>
|
||||
<div> {{ Math.min(limit, projectsInfo?.count || 0) }} of {{ projectsInfo?.count || 0
|
||||
}}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<UPagination v-model="page" :page-count="limit" :total="projectsInfo?.count || 0" />
|
||||
</div>
|
||||
|
||||
<UPopover class="w-[20rem]" :popper="{ placement: 'bottom' }">
|
||||
<UButton class="w-full" color="primary" variant="solid">
|
||||
<div class="flex items-center justify-center w-full gap-2">
|
||||
<i class="i-heroicons-calendar-days-20-solid"></i>
|
||||
{{ selected.start.toLocaleDateString() }} - {{ selected.end.toLocaleDateString() }}
|
||||
</div>
|
||||
</UButton>
|
||||
<template #panel="{ close }">
|
||||
<div class="flex items-center sm:divide-x divide-gray-200 dark:divide-gray-800">
|
||||
<div class="hidden sm:flex flex-col py-4">
|
||||
<UButton v-for="(range, index) in ranges" :key="index" :label="range.label" color="gray"
|
||||
variant="ghost" class="rounded-none px-6"
|
||||
:class="[isRangeSelected(range.duration) ? 'bg-gray-100 dark:bg-gray-800' : 'hover:bg-gray-50 dark:hover:bg-gray-800/50']"
|
||||
truncate @click="selectRange(range.duration)" />
|
||||
</div>
|
||||
|
||||
<DatePicker v-model="selected" @close="close" />
|
||||
</div>
|
||||
</template>
|
||||
</UPopover>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="w-[80%]">
|
||||
<div v-if="pendingMetrics"> Loading... </div>
|
||||
<div class="flex gap-10 flex-wrap" v-if="!pendingMetrics && metrics">
|
||||
<div> Projects: {{ metrics.totalProjects }} ({{ metrics.premiumProjects }} premium) </div>
|
||||
<div>
|
||||
Total visits: {{ formatNumberK(metrics.totalVisits) }}
|
||||
</div>
|
||||
<div>
|
||||
Active: {{ metrics.totalProjects - metrics.deadProjects }} |
|
||||
Dead: {{ metrics.deadProjects }}
|
||||
</div>
|
||||
<div>
|
||||
Total events: {{ formatNumberK(metrics.totalEvents) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div
|
||||
class="cursor-default flex justify-center flex-wrap gap-6 mb-[4rem] mt-4 overflow-auto h-full pt-6 pb-[8rem]">
|
||||
|
||||
<AdminOverviewProjectCard v-if="!pendingProjects" :key="project._id.toString()" :project="project"
|
||||
class="w-[26rem]" v-for="project of projectsInfo?.projects" />
|
||||
|
||||
<div v-if="pendingProjects"> Loading...</div>
|
||||
|
||||
</div>
|
||||
<div class="flex flex-col gap-10 h-full overflow-hidden">
|
||||
<AdminOverviewCounts></AdminOverviewCounts>
|
||||
<AdminOverviewUsers></AdminOverviewUsers>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
</template>
|
||||
28
dashboard/components/admin/OverviewCounts.vue
Normal file
28
dashboard/components/admin/OverviewCounts.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
const { data: counts } = useAuthFetch('/api/admin/counts');
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<div v-if="counts" class="flex justify-center gap-20">
|
||||
<div class="flex gap-4">
|
||||
<Label> Projects: {{ counts.projects }} </Label>
|
||||
<Label> Active: {{ counts.active }} </Label>
|
||||
<Label> Dead: {{ counts.dead }} </Label>
|
||||
</div>
|
||||
<div class="flex gap-4">
|
||||
<Label> Users: {{ counts.users }} </Label>
|
||||
<Label> Paid: {{ counts.paid }} </Label>
|
||||
<Label> Appsumo: {{ counts.appsumo }} </Label>
|
||||
<Label> Free: {{ counts.free_trial }} </Label>
|
||||
<Label> FreeEnd: {{ counts.free_trial_ended }} </Label>
|
||||
</div>
|
||||
<div class="flex gap-4">
|
||||
<Label> Visits: {{ formatNumberK(counts.visits, 2) }} </Label>
|
||||
<Label> Events: {{ formatNumberK(counts.events, 2) }} </Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
69
dashboard/components/admin/OverviewPopoverProject.vue
Normal file
69
dashboard/components/admin/OverviewPopoverProject.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
const props = defineProps<{ project: any }>();
|
||||
const loading = ref<boolean>(true);
|
||||
|
||||
const domains = ref<string[]>([]);
|
||||
|
||||
const { list, containerProps, wrapperProps } = useVirtualList(domains, { itemHeight: 40 });
|
||||
|
||||
async function loadData() {
|
||||
domains.value.length = 0;
|
||||
loading.value = true;
|
||||
await useCatch({
|
||||
async action() {
|
||||
const res = await useAuthFetchSync<string[]>(`/api/admin/domains?pid=${props.project._id.toString()}`);
|
||||
return res;
|
||||
},
|
||||
async onSuccess(data) {
|
||||
domains.value = data;
|
||||
},
|
||||
})
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
const projectStore = useProjectStore();
|
||||
|
||||
function stealProject() {
|
||||
projectStore.projects.push(props.project);
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Popover @update:open="loadData()">
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="link">
|
||||
{{ props.project.name }} -
|
||||
{{ props.project.counts[0].visits }}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-full h-full">
|
||||
|
||||
<div class="flex flex-col w-full h-full">
|
||||
|
||||
<div class="flex justify-center">
|
||||
<Loader v-if="loading"></Loader>
|
||||
<div v-if="!loading && domains.length == 0">No domains</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!loading && domains.length > 0" class="flex flex-col">
|
||||
|
||||
<div class="flex justify-center pb-2 gap-2">
|
||||
<Button @click="stealProject()" size="sm">Steal</Button>
|
||||
<Label> {{ domains.length }} domains</Label>
|
||||
</div>
|
||||
|
||||
<div v-bind="containerProps" class="h-[18rem] w-[25rem]">
|
||||
<div v-bind="wrapperProps" class="flex flex-col">
|
||||
<div v-for="(domain, index) of list" class="!h-[40px]" :key="index">
|
||||
<Separator v-if="index < domains.length - 1" class="my-2"></Separator>
|
||||
<div>{{ domain.data }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
201
dashboard/components/admin/OverviewUsers.vue
Normal file
201
dashboard/components/admin/OverviewUsers.vue
Normal file
@@ -0,0 +1,201 @@
|
||||
<script lang="ts" setup>
|
||||
import type { DateRange } from 'reka-ui'
|
||||
import { RangeCalendar } from '@/components/ui/range-calendar'
|
||||
import { CalendarIcon, LucideSearch, X } from 'lucide-vue-next'
|
||||
import { CalendarDate, DateFormatter, getLocalTimeZone } from '@internationalized/date'
|
||||
|
||||
const currentPage = ref<number>(1);
|
||||
|
||||
const currentSorting = ref<string>('usage-more')
|
||||
|
||||
const popoverOpen = ref<boolean>(false);
|
||||
|
||||
const search = ref<string>('');
|
||||
const searchRequest = ref<string>('');
|
||||
|
||||
function clearSearchData() {
|
||||
searchRequest.value = '';
|
||||
search.value = '';
|
||||
}
|
||||
|
||||
function searchData() {
|
||||
searchRequest.value = search.value;
|
||||
}
|
||||
|
||||
const value = ref<DateRange>({
|
||||
start: new CalendarDate(new Date().getFullYear(), new Date().getUTCMonth() + 1, 1),
|
||||
end: new CalendarDate(new Date().getFullYear(), new Date().getUTCMonth() + 1, new Date().getDate())
|
||||
}) as Ref<DateRange>;
|
||||
|
||||
const df = new DateFormatter('en-US', { dateStyle: 'medium' })
|
||||
|
||||
const { data: info } = useAuthFetch(() => `/api/admin/users?page=${currentPage.value}&sort=${currentSorting.value}&from=${value.value.start}&to=${value.value.end}&search=${searchRequest.value}`);
|
||||
|
||||
function onPageChange(page: number) {
|
||||
currentPage.value = page;
|
||||
}
|
||||
|
||||
function isActive(u: any) {
|
||||
const updates: Date[] = u.projects.map((e: any) => new Date(e.counts[0].updated_at));
|
||||
const lastUpdates = updates.toSorted((a, b) => b.getTime() - a.getTime());
|
||||
if (lastUpdates.length == 0) return false;
|
||||
const lastUpdate = lastUpdates[0];
|
||||
if (lastUpdate.getTime() < Date.now() - 1000 * 60 * 60 * 24 * 3) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function setDate(hours: number) {
|
||||
const start = new Date(Date.now() - hours * 1000 * 60 * 60);
|
||||
value.value.start = new CalendarDate(start.getFullYear(), start.getUTCMonth() + 1, start.getDate());
|
||||
value.value.end = new CalendarDate(new Date().getFullYear(), new Date().getUTCMonth() + 1, new Date().getDate());
|
||||
}
|
||||
|
||||
// function getLastUpdate(u: any) {
|
||||
// const updates: Date[] = u.projects.map((e: any) => new Date(e.counts[0].updated_at));
|
||||
// const lastUpdates = updates.toSorted((a, b) => b.getTime() - a.getTime());
|
||||
// if (lastUpdates.length == 0) return '-';
|
||||
// const lastUpdate = lastUpdates[0];
|
||||
// return lastUpdate.toLocaleDateString('it-IT');
|
||||
// }
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-6 h-full overflow-hidden">
|
||||
<div class="flex justify-center gap-8">
|
||||
|
||||
<Select v-model="currentSorting">
|
||||
<SelectTrigger>
|
||||
<SelectValue class="w-[8rem]">
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="newer">
|
||||
Newer
|
||||
</SelectItem>
|
||||
<SelectItem value="older">
|
||||
Older
|
||||
</SelectItem>
|
||||
<SelectItem value="usage-more">
|
||||
More usage %
|
||||
</SelectItem>
|
||||
<SelectItem value="usage-less">
|
||||
Less usage %
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Popover v-model:open="popoverOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="outline">
|
||||
<CalendarIcon class="mr-2 h-4 w-4" />
|
||||
<template v-if="value.start">
|
||||
<template v-if="value.end">
|
||||
{{ df.format(value.start.toDate(getLocalTimeZone())) }} - {{
|
||||
df.format(value.end.toDate(getLocalTimeZone())) }}
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
{{ df.format(value.start.toDate(getLocalTimeZone())) }}
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
Pick a date
|
||||
</template>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-auto p-4 flex flex-col items-end relative z-[90]">
|
||||
<RangeCalendar v-model="value" initial-focus :number-of-months="2"
|
||||
@update:start-value="(startDate) => value.start = startDate" />
|
||||
<Button @click="popoverOpen = false;"> Confirm </Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Button size="sm"> Timeframe </Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem @click="setDate(365 * 10 * 24)">
|
||||
All Time
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="setDate(48)">
|
||||
Last day
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="setDate(30 * 24)">
|
||||
Last 30 days
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="setDate(60 * 24)">
|
||||
Last 60 days
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem @click="setDate(90 * 24)">
|
||||
Last 90 days
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Input class="w-[20rem]" v-model="search" />
|
||||
<Button :disabled="search == searchRequest" @click="searchData()" size="icon">
|
||||
<LucideSearch></LucideSearch>
|
||||
</Button>
|
||||
<Button v-if="searchRequest.length > 0" @click="clearSearchData()" size="icon">
|
||||
<X></X>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<Pagination v-if="info" @update:page="onPageChange" v-slot="{ page }" :items-per-page="20" :total="info.count"
|
||||
:default-page="currentPage">
|
||||
<PaginationContent v-slot="{ items }">
|
||||
<PaginationPrevious />
|
||||
<template v-for="(item, index) in items" :key="index">
|
||||
<PaginationItem v-if="item.type === 'page'" :value="item.value" :is-active="item.value === page">
|
||||
{{ item.value }}
|
||||
</PaginationItem>
|
||||
</template>
|
||||
<PaginationEllipsis v-if="info.count > 20 * 4" :index="4" />
|
||||
<PaginationNext />
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
<div class="overflow-y-auto pb-10">
|
||||
<div class="grid grid-cols-2 gap-4" v-if="info">
|
||||
<Card v-for="user of info.users">
|
||||
<CardContent>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex gap-2">
|
||||
<div class="size-3 rounded-full mt-[1px] bg-red-200" :class="{
|
||||
'!bg-green-200': isActive(user)
|
||||
}"></div>
|
||||
<!-- <Label> {{ getLastUpdate(user) }} </Label> -->
|
||||
<Label> {{ user.email }} </Label>
|
||||
<Label class="text-muted-foreground">
|
||||
{{ new Date(user.created_at).toLocaleDateString('it-IT') }}
|
||||
</Label>
|
||||
<Label class="text-muted-foreground ml-2">
|
||||
{{ user.visits + user.events }} / {{ user.limit }}
|
||||
({{ Math.floor(100 / user.limit * (user.visits + user.events)) }}%)
|
||||
</Label>
|
||||
</div>
|
||||
<div>
|
||||
<Progress
|
||||
:model-value="Math.min(Math.floor(100 / user.limit * (user.visits + user.events)), 100)"></Progress>
|
||||
</div>
|
||||
<div class="flex gap-8 flex-wrap">
|
||||
<div v-for="p of user.projects">
|
||||
<AdminOverviewPopoverProject :project="p">
|
||||
</AdminOverviewPopoverProject>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,151 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
import { useSelectMenuStyle } from '~/composables/ui/useSelectMenuStyle';
|
||||
import type { TAdminUser } from '~/server/api/admin/users';
|
||||
import { sub, format, isSameDay, type Duration, startOfDay, endOfDay } from 'date-fns'
|
||||
|
||||
|
||||
|
||||
const filterText = ref<string>('');
|
||||
|
||||
watch(filterText, () => {
|
||||
page.value = 1;
|
||||
})
|
||||
|
||||
function isRangeSelected(duration: Duration) {
|
||||
return isSameDay(selected.value.start, sub(new Date(), duration)) && isSameDay(selected.value.end, new Date())
|
||||
}
|
||||
|
||||
function selectRange(duration: Duration) {
|
||||
selected.value = { start: sub(new Date(), duration), end: new Date() }
|
||||
}
|
||||
|
||||
const ranges = [
|
||||
{ label: 'Last 7 days', duration: { days: 7 } },
|
||||
{ label: 'Last 14 days', duration: { days: 14 } },
|
||||
{ label: 'Last 30 days', duration: { days: 30 } },
|
||||
{ label: 'Last 3 months', duration: { months: 3 } },
|
||||
{ label: 'Last 6 months', duration: { months: 6 } },
|
||||
{ label: 'Last year', duration: { years: 1 } }
|
||||
]
|
||||
const selected = ref<{ start: Date, end: Date }>({ start: sub(new Date(), { days: 14 }), end: new Date() })
|
||||
|
||||
const filter = computed(() => {
|
||||
return JSON.stringify({
|
||||
$or: [
|
||||
{ given_name: { $regex: `.*${filterText.value}.*`, $options: "i" } },
|
||||
{ email: { $regex: `.*${filterText.value}.*`, $options: "i" } }
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
const page = ref<number>(1);
|
||||
|
||||
const ordersList = [
|
||||
{ label: 'created_at -->', id: '{ "created_at": 1 }' },
|
||||
{ label: 'created_at <--', id: '{ "created_at": -1 }' },
|
||||
]
|
||||
|
||||
const order = ref<string>('{ "created_at": -1 }');
|
||||
|
||||
|
||||
const limitList = [
|
||||
{ label: '10', id: 10 },
|
||||
{ label: '20', id: 20 },
|
||||
{ label: '50', id: 50 },
|
||||
{ label: '100', id: 100 },
|
||||
]
|
||||
|
||||
const limit = ref<number>(20);
|
||||
|
||||
const { data: usersInfo, pending: pendingUsers } = await useFetch<{ count: number, users: TAdminUser[] }>(
|
||||
() => `/api/admin/users?page=${page.value - 1}&limit=${limit.value}&sortQuery=${order.value}&filterQuery=${filter.value}&filterFrom=${selected.value.start.toISOString()}&filterTo=${selected.value.end.toISOString()}`,
|
||||
signHeaders()
|
||||
);
|
||||
|
||||
const { uiMenu } = useSelectMenuStyle();
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mt-6 h-full">
|
||||
|
||||
|
||||
<div class="flex flex-col items-center gap-6">
|
||||
|
||||
<div class="flex items-center gap-10 px-10">
|
||||
<div class="flex gap-2 items-center">
|
||||
<div>Order:</div>
|
||||
<USelectMenu :uiMenu="uiMenu" class="w-[12rem]" placeholder="Order" :options="ordersList"
|
||||
value-attribute="id" option-attribute="label" v-model="order">
|
||||
</USelectMenu>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="flex gap-2 items-center">
|
||||
<div>Limit:</div>
|
||||
<USelectMenu :uiMenu="uiMenu" class="w-[12rem]" placeholder="Limit" :options="limitList"
|
||||
value-attribute="id" option-attribute="label" v-model="limit">
|
||||
</USelectMenu>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 items-center">
|
||||
<LyxUiInput placeholder="Search user" class="px-2 py-1" v-model="filterText"></LyxUiInput>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-centet gap-10">
|
||||
<div class="flex gap-2 items-center">
|
||||
<div>Page {{ page }} </div>
|
||||
<div>
|
||||
{{ Math.min(limit, usersInfo?.count || 0) }}
|
||||
of
|
||||
{{ usersInfo?.count || 0 }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<UPagination v-model="page" :page-count="limit" :total="usersInfo?.count || 0" />
|
||||
</div>
|
||||
|
||||
<UPopover class="w-[20rem]" :popper="{ placement: 'bottom' }">
|
||||
<UButton class="w-full" color="primary" variant="solid">
|
||||
<div class="flex items-center justify-center w-full gap-2">
|
||||
<i class="i-heroicons-calendar-days-20-solid"></i>
|
||||
{{ selected.start.toLocaleDateString() }} - {{ selected.end.toLocaleDateString() }}
|
||||
</div>
|
||||
</UButton>
|
||||
<template #panel="{ close }">
|
||||
<div class="flex items-center sm:divide-x divide-gray-200 dark:divide-gray-800">
|
||||
<div class="hidden sm:flex flex-col py-4">
|
||||
<UButton v-for="(range, index) in ranges" :key="index" :label="range.label" color="gray"
|
||||
variant="ghost" class="rounded-none px-6"
|
||||
:class="[isRangeSelected(range.duration) ? 'bg-gray-100 dark:bg-gray-800' : 'hover:bg-gray-50 dark:hover:bg-gray-800/50']"
|
||||
truncate @click="selectRange(range.duration)" />
|
||||
</div>
|
||||
|
||||
<DatePicker v-model="selected" @close="close" />
|
||||
</div>
|
||||
</template>
|
||||
</UPopover>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div
|
||||
class="cursor-default flex justify-center flex-wrap gap-6 mb-[4rem] mt-4 overflow-auto h-full pt-6 pb-[8rem]">
|
||||
|
||||
<AdminUsersUserCard v-if="!pendingUsers" :key="user._id.toString()" :user="user" class="w-[26rem]"
|
||||
v-for="user of usersInfo?.users" />
|
||||
|
||||
<div v-if="pendingUsers"> Loading...</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -1,132 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { ChartData, ChartOptions } from 'chart.js';
|
||||
import { useLineChart, LineChart } from 'vue-chart-3';
|
||||
import * as datefns from 'date-fns';
|
||||
|
||||
const errored = ref<boolean>(false);
|
||||
|
||||
const props = defineProps<{
|
||||
labels: string[],
|
||||
title: string,
|
||||
datasets: {
|
||||
points: number[],
|
||||
color: string,
|
||||
name: string
|
||||
}[]
|
||||
}>();
|
||||
|
||||
const chartOptions = ref<ChartOptions<'line'>>({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'nearest',
|
||||
axis: 'x',
|
||||
includeInvisible: true
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
ticks: { display: true },
|
||||
grid: {
|
||||
display: true,
|
||||
drawBorder: false,
|
||||
color: '#CCCCCC22',
|
||||
// borderDash: [5, 10]
|
||||
},
|
||||
},
|
||||
x: {
|
||||
ticks: { display: true },
|
||||
grid: {
|
||||
display: true,
|
||||
drawBorder: false,
|
||||
color: '#CCCCCC22',
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: { display: true },
|
||||
title: {
|
||||
display: true,
|
||||
text: props.title
|
||||
},
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
titleFont: { size: 16, weight: 'bold' },
|
||||
bodyFont: { size: 14 },
|
||||
padding: 10,
|
||||
cornerRadius: 4,
|
||||
boxPadding: 10,
|
||||
caretPadding: 20,
|
||||
yAlign: 'bottom',
|
||||
xAlign: 'center',
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const chartData = ref<ChartData<'line'>>({
|
||||
labels: props.labels.map(e => {
|
||||
try {
|
||||
return datefns.format(new Date(e), 'dd/MM');
|
||||
} catch (ex) {
|
||||
return e;
|
||||
}
|
||||
}),
|
||||
datasets: props.datasets.map(e => ({
|
||||
data: e.points,
|
||||
label: e.name,
|
||||
backgroundColor: [e.color + '00'],
|
||||
borderColor: e.color,
|
||||
borderWidth: 2,
|
||||
fill: true,
|
||||
tension: 0.45,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 10,
|
||||
type: 'line'
|
||||
} as any))
|
||||
});
|
||||
|
||||
|
||||
const { lineChartProps, lineChartRef } = useLineChart({ chartData: chartData, options: chartOptions });
|
||||
|
||||
function createGradient(startColor: string) {
|
||||
const c = document.createElement('canvas');
|
||||
const ctx = c.getContext("2d");
|
||||
let gradient: any = `${startColor}22`;
|
||||
if (ctx) {
|
||||
gradient = ctx.createLinearGradient(0, 25, 0, 300);
|
||||
gradient.addColorStop(0, `${startColor}99`);
|
||||
gradient.addColorStop(0.35, `${startColor}66`);
|
||||
gradient.addColorStop(1, `${startColor}22`);
|
||||
} else {
|
||||
console.warn('Cannot get context for gradient');
|
||||
}
|
||||
|
||||
return gradient;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
// chartData.value.datasets.forEach(dataset => {
|
||||
// if (dataset.borderColor && dataset.borderColor.toString().startsWith('#')) {
|
||||
// dataset.backgroundColor = [createGradient(dataset.borderColor as string)]
|
||||
// } else {
|
||||
// dataset.backgroundColor = [createGradient('#3d59a4')]
|
||||
// }
|
||||
// });
|
||||
} catch (ex) {
|
||||
errored.value = true;
|
||||
console.error(ex);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="errored"> ERROR CREATING CHART </div>
|
||||
<LineChart v-if="!errored" ref="lineChartRef" v-bind="lineChartProps"> </LineChart>
|
||||
</div>
|
||||
</template>
|
||||
17
dashboard/components/admin/backend/Operation.vue
Normal file
17
dashboard/components/admin/backend/Operation.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
defineProps<{ operation: any }>();
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card>
|
||||
<CardContent class="flex items-center gap-2">
|
||||
<div class="size-3 bg-gray-300 rounded-full"></div>
|
||||
<div class="w-[20rem]"> {{ operation.shard }} </div>
|
||||
<div class="w-[20rem]"> {{ operation.ns }} </div>
|
||||
<div class="w-[10rem]"> {{ formatTime(operation.totalOperationTimeElapsedSecs * 1000) }} </div>
|
||||
<div class> {{ operation.donorState ?? 'NO_STATE' }} </div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
29
dashboard/components/admin/backend/ShardData.vue
Normal file
29
dashboard/components/admin/backend/ShardData.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
const props = defineProps<{ shardName: string, count: number, totalSize: number, totalIndexSize: number, chunks?: number }>();
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex gap-2 items-center">
|
||||
<Icon name="uil:puzzle-piece" :size="20"></Icon>
|
||||
<div class="w-[8rem]">{{ shardName }}</div>
|
||||
|
||||
<div class="w-[9rem] flex gap-2 items-center">
|
||||
<Icon :size="20" name="ph:files"></Icon>
|
||||
<div> {{ formatNumberK(count, 2) }} </div>
|
||||
</div>
|
||||
<div class="w-[9rem] flex gap-2 items-center">
|
||||
<Icon :size="20" name="lucide:weight"></Icon>
|
||||
<div> {{ formatBytes(totalSize, 2) }} </div>
|
||||
</div>
|
||||
<div class="w-[9rem] flex gap-2 items-center">
|
||||
<Icon :size="20" name="material-symbols:key-vertical"></Icon>
|
||||
<div> {{ formatBytes(totalIndexSize, 2) }} </div>
|
||||
</div>
|
||||
<div class="w-[9rem] flex gap-2 items-center" v-if="chunks">
|
||||
<Icon :size="20" name="fluent:puzzle-cube-piece-20-filled"></Icon>
|
||||
<div> {{ chunks }} </div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,48 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import type { TAdminProject } from '~/server/api/admin/projects';
|
||||
|
||||
const props = defineProps<{ pid: string }>();
|
||||
|
||||
const { data: projectInfo, refresh, pending } = useFetch<{ domains: { _id: string }[], project: TAdminProject }>(
|
||||
() => `/api/admin/project_info?pid=${props.pid}`,
|
||||
signHeaders(),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<div class="mt-6 h-full flex flex-col gap-10 w-full" v-if="!pending">
|
||||
|
||||
<div>
|
||||
<LyxUiButton type="secondary" @click="refresh"> Refresh </LyxUiButton>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center gap-10" v-if="projectInfo">
|
||||
|
||||
<AdminOverviewProjectCard :project="projectInfo.project" class="w-[30rem] shrink-0" />
|
||||
|
||||
<AdminMiniChart class="max-w-[40rem]" :pid="pid"></AdminMiniChart>
|
||||
</div>
|
||||
|
||||
<div v-if="projectInfo" class="flex flex-col">
|
||||
|
||||
<div>Domains:</div>
|
||||
|
||||
<div class="flex flex-wrap gap-8 mt-8">
|
||||
|
||||
<div v-for="domain of projectInfo.domains">
|
||||
{{ domain._id }}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div v-if="pending">
|
||||
Loading...
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -1,134 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
import type { TAdminProject } from '~/server/api/admin/projects';
|
||||
import { getPlanFromId } from '~/shared/data/PREMIUM';
|
||||
|
||||
|
||||
import { AdminDialogProjectDetails } from '#components';
|
||||
|
||||
const { openDialogEx } = useCustomDialog();
|
||||
|
||||
function showProjectDetails(pid: string) {
|
||||
openDialogEx(AdminDialogProjectDetails, {
|
||||
params: { pid }
|
||||
})
|
||||
}
|
||||
|
||||
const props = defineProps<{ project: TAdminProject }>();
|
||||
|
||||
|
||||
const logBg = computed(() => {
|
||||
|
||||
const day = 1000 * 60 * 60 * 24;
|
||||
const week = 1000 * 60 * 60 * 24 * 7;
|
||||
|
||||
const lastLoggedAtDate = new Date(props.project.last_log_at || 0);
|
||||
|
||||
if (lastLoggedAtDate.getTime() > Date.now() - day) {
|
||||
return 'bg-green-500'
|
||||
} else if (lastLoggedAtDate.getTime() > Date.now() - week) {
|
||||
return 'bg-yellow-500'
|
||||
} else {
|
||||
return 'bg-red-500'
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
|
||||
const dateDiffDays = computed(() => {
|
||||
const res = (Date.now() - new Date(props.project.last_log_at || 0).getTime()) / (1000 * 60 * 60 * 24)
|
||||
if (res > -1 && res < 1) return 0;
|
||||
return res;
|
||||
});
|
||||
|
||||
const usageLabel = computed(() => {
|
||||
return formatNumberK(props.project.limit_total) + ' / ' + formatNumberK(props.project.limit_max)
|
||||
});
|
||||
|
||||
const usagePercentLabel = computed(() => {
|
||||
const percent = 100 / props.project.limit_max * props.project.limit_total;
|
||||
return `~ ${percent.toFixed(1)}%`;
|
||||
});
|
||||
|
||||
const usageAiLabel = computed(() => {
|
||||
return formatNumberK(props.project.limit_ai_messages) + ' / ' + formatNumberK(props.project.limit_ai_max);
|
||||
}
|
||||
|
||||
); const usageAiPercentLabel = computed(() => {
|
||||
const percent = 100 / props.project.limit_ai_max * props.project.limit_ai_messages;
|
||||
return `~ ${percent.toFixed(1)}%`
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="poppins outline outline-[1px] outline-lyx-widget-lighter p-3 rounded-md relative h-fit">
|
||||
|
||||
<div class="absolute top-1 left-2 text-[.8rem] text-lyx-text-dark flex items-center gap-2">
|
||||
<div :class="logBg" class="h-3 w-3 rounded-full"> </div>
|
||||
<div class="mt-1"> {{ dateDiffDays.toFixed(0) }} days </div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4 justify-center text-[.9rem]">
|
||||
<UTooltip :text="`PRICE_ID: ${project.premium_type}`">
|
||||
<div class="font-medium text-lyx-text-dark">
|
||||
{{ getPlanFromId(project.premium_type)?.TAG?.replace('APPSUMO', 'AS') ?? 'ERROR' }}
|
||||
</div>
|
||||
</UTooltip>
|
||||
<div class="text-lyx-text-darker">
|
||||
{{ new Date(project.created_at).toLocaleDateString('it-IT') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-5 justify-center">
|
||||
<div @click="showProjectDetails(project._id.toString())" class="font-medium hover:text-lyx-primary cursor-pointer">
|
||||
{{ project.name }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center mt-2">
|
||||
<div class="flex gap-4">
|
||||
<div class="flex gap-2">
|
||||
<div class="text-right"> Visits:</div>
|
||||
<div>{{ formatNumberK(project.visits || 0) }}</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<div class="text-right"> Events:</div>
|
||||
<div>{{ formatNumberK(project.events || 0) }}</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<div class="text-right"> Sessions:</div>
|
||||
<div>{{ formatNumberK(project.sessions || 0) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LyxUiSeparator class="my-2" />
|
||||
|
||||
<div class="mb-2">
|
||||
<UProgress :value="project.limit_visits + project.limit_events" :max="project.limit_max"></UProgress>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-6 justify-around">
|
||||
<div class="flex gap-1">
|
||||
<div>
|
||||
{{ usageLabel }}
|
||||
</div>
|
||||
<div class="text-lyx-text-dark">
|
||||
{{ usagePercentLabel }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<div>
|
||||
{{ usageAiLabel }}
|
||||
</div>
|
||||
<div class="text-lyx-text-dark">
|
||||
{{ usageAiPercentLabel }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -1,135 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
import type { TAdminProject } from '~/server/api/admin/projects';
|
||||
import type { TAdminUser } from '~/server/api/admin/users';
|
||||
import { getPlanFromId } from '~/shared/data/PREMIUM';
|
||||
|
||||
import { AdminDialogProjectDetails } from '#components';
|
||||
|
||||
const { openDialogEx } = useCustomDialog();
|
||||
|
||||
function showProjectDetails(pid: string) {
|
||||
openDialogEx(AdminDialogProjectDetails, {
|
||||
params: { pid }
|
||||
})
|
||||
}
|
||||
|
||||
const props = defineProps<{ user: TAdminUser }>();
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<div class="poppins outline outline-[1px] outline-lyx-widget-lighter p-3 rounded-md relative max-h-[15rem]">
|
||||
<div class="flex gap-4 justify-center text-[.9rem]">
|
||||
<div class="font-medium text-lyx-text-dark">
|
||||
{{ user.name ?? user.given_name }}
|
||||
</div>
|
||||
<div class="text-lyx-text-darker">
|
||||
{{ new Date(user.created_at).toLocaleDateString('it-IT') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-5 justify-center">
|
||||
<div class="font-medium">
|
||||
{{ user.email }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LyxUiSeparator class="my-2" />
|
||||
|
||||
<div class="flex flex-col text-[.9rem]">
|
||||
<div class="flex gap-2" v-for="project of user.projects">
|
||||
<div class="text-lyx-text-darker">
|
||||
{{ new Date(project.created_at).toLocaleDateString('it-IT') }}
|
||||
</div>
|
||||
<UTooltip :text="`PRICE_ID: ${project.premium_type}`">
|
||||
<div class="font-medium text-lyx-text-dark">
|
||||
{{ getPlanFromId(project.premium_type)?.TAG?.replace('APPSUMO', 'AS') ?? 'ERROR' }}
|
||||
</div>
|
||||
</UTooltip>
|
||||
|
||||
<div @click="showProjectDetails(project._id.toString())"
|
||||
class="ml-1 hover:text-lyx-primary cursor-pointer">
|
||||
{{ project.name }}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<!-- <div class="poppins outline outline-[1px] outline-lyx-widget-lighter p-3 rounded-md relative">
|
||||
|
||||
<div class="absolute top-1 left-2 text-[.8rem] text-lyx-text-dark flex items-center gap-2">
|
||||
<div :class="logBg" class="h-3 w-3 rounded-full"> </div>
|
||||
<div class="mt-1"> {{ dateDiffDays.toFixed(0) }} days </div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4 justify-center text-[.9rem]">
|
||||
<UTooltip :text="`PRICE_ID: ${project.premium_type}`">
|
||||
<div class="font-medium text-lyx-text-dark">
|
||||
{{ getPlanFromId(project.premium_type)?.TAG?.replace('APPSUMO', 'AS') ?? 'ERROR' }}
|
||||
</div>
|
||||
</UTooltip>
|
||||
<div class="text-lyx-text-darker">
|
||||
{{ new Date(project.created_at).toLocaleDateString('it-IT') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-5 justify-center">
|
||||
<div class="font-medium">
|
||||
{{ project.name }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center mt-2">
|
||||
<div class="flex gap-4">
|
||||
<div class="flex gap-2">
|
||||
<div class="text-right"> Visits:</div>
|
||||
<div>{{ formatNumberK(project.visits || 0) }}</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<div class="text-right"> Events:</div>
|
||||
<div>{{ formatNumberK(project.events || 0) }}</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<div class="text-right"> Sessions:</div>
|
||||
<div>{{ formatNumberK(project.sessions || 0) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LyxUiSeparator class="my-2" />
|
||||
|
||||
<div class="mb-2">
|
||||
<UProgress :value="project.limit_visits + project.limit_events" :max="project.limit_max"></UProgress>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-6 justify-around">
|
||||
<div class="flex gap-1">
|
||||
<div>
|
||||
{{ usageLabel }}
|
||||
</div>
|
||||
<div class="text-lyx-text-dark">
|
||||
{{ usagePercentLabel }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<div>
|
||||
{{ usageAiLabel }}
|
||||
</div>
|
||||
<div class="text-lyx-text-dark">
|
||||
{{ usageAiPercentLabel }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div> -->
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
Reference in New Issue
Block a user