From 1fc4082112699f4e716b47dc6393ca3b34622a34 Mon Sep 17 00:00:00 2001
From: Ivan Borshchov Install CHart.js library into your main package (near Create a Vue component in the 🫨 use https://flowbite.com/ to copy-paste pre-designed tailwind design blocks for your pagesindex.ts
):npm install apexcharts --save
custom
directory of your project, e.g. Dashboard.vue
:<template>
<div class="px-4 py-8 bg-blue-50 dark:bg-gray-900 dark:shadow-none h-screen">
<h1 class="mb-4 text-xl font-extrabold text-gray-900 dark:text-white md:text-2xl lg:text-3xl"><span
class="text-transparent bg-clip-text bg-gradient-to-r to-emerald-600 from-sky-400">Appartments</span>
Statistics.</h1>
<div class="grid grid-cols-3 gap-4">
<div class="max-w-md w-full bg-white rounded-lg shadow dark:bg-gray-800 p-4 md:p-6" v-if="data">
<div class="flex justify-between">
<div>
<h5 class="leading-none text-3xl font-bold text-gray-900 dark:text-white pb-2">{{ data.totalAparts }}</h5>
<p class="text-base font-normal text-gray-500 dark:text-gray-400">Apartments last 7 days</p>
</div>
</div>
<div id="area-chart"></div>
</div>
<div class="w-full bg-white rounded-lg shadow dark:bg-gray-800 p-4 md:p-6 row-span-2 col-span-2" v-if="data">
<div class="grid grid-cols-2 py-3">
<dl>
<dt class="text-base font-normal text-gray-500 dark:text-gray-400 pb-1">Listed price</dt>
<dd class="leading-none text-xl font-bold text-green-500 dark:text-green-400">{{
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(
data.totalListedPrice,
) }}
</dd>
</dl>
<dl>
<dt class="text-base font-normal text-gray-500 dark:text-gray-400 pb-1">Unlisted price</dt>
<dd class="leading-none text-xl font-bold text-red-600 dark:text-red-500">{{
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(
data.totalUnlistedPrice,
) }}
</dd>
</dl>
</div>
<div id="bar-chart"></div>
</div>
<div class="max-w-md w-full bg-white rounded-lg shadow dark:bg-gray-800 p-4 md:p-6" v-if="data">
<div class="flex justify-between mb-5">
<div>
<p class="text-base font-normal text-gray-500 dark:text-gray-400">
Unlisted vs Listed price
</p>
</div>
</div>
<div id="size-chart" class="[&>div]:mx-auto"></div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import ApexCharts from 'apexcharts';
import dayjs from 'dayjs';
const data = ref({});
const optionsC1 = {
chart: {
height: 145,
type: "area",
fontFamily: "Inter, sans-serif",
dropShadow: {
enabled: false,
},
toolbar: {
show: false,
},
},
tooltip: {
enabled: true,
x: {
show: false,
},
},
fill: {
type: "gradient",
gradient: {
opacityFrom: 0.55,
opacityTo: 0,
shade: "#1C64F2",
gradientToColors: ["#1C64F2"],
},
},
dataLabels: {
enabled: false,
},
stroke: {
width: 6,
},
grid: {
show: false,
strokeDashArray: 4,
padding: {
left: 2,
right: 2,
top: 0
},
},
series: [
{
name: "Added appartments",
data: [],
color: "#1A56DB",
},
],
xaxis: {
categories: [],
labels: {
show: false,
},
axisBorder: {
show: false,
},
axisTicks: {
show: false,
},
},
yaxis: {
show: false,
},
};
const optionsC2 = {
series: [
{
name: "Listed",
color: "#31C48D",
data: [],
},
{
name: "Unlisted",
data: [],
color: "#F05252",
}
],
chart: {
sparkline: {
enabled: false,
},
type: "bar",
width: "100%",
height: 400,
toolbar: {
show: false,
}
},
fill: {
opacity: 1,
},
plotOptions: {
bar: {
horizontal: true,
columnWidth: "100%",
borderRadiusApplication: "end",
borderRadius: 6,
dataLabels: {
position: "top",
},
},
},
legend: {
show: true,
position: "bottom",
},
dataLabels: {
enabled: false,
},
tooltip: {
shared: true,
intersect: false,
formatter: function (value) {
return value
},
},
xaxis: {
labels: {
show: true,
style: {
fontFamily: "Inter, sans-serif",
cssClass: 'text-xs font-normal fill-gray-500 dark:fill-gray-400'
},
formatter: function (value) {
return value
}
},
categories: [],
axisTicks: {
show: false,
},
axisBorder: {
show: false,
},
},
yaxis: {
labels: {
show: true,
style: {
fontFamily: "Inter, sans-serif",
cssClass: 'text-xs font-normal fill-gray-500 dark:fill-gray-400'
}
}
},
grid: {
show: true,
strokeDashArray: 4,
padding: {
left: 10,
right: 2,
// top: -20
},
},
fill: {
opacity: 1,
}
}
const optionsC3 = {
chart: {
height: 145,
type: "area",
fontFamily: "Inter, sans-serif",
dropShadow: {
enabled: false,
},
toolbar: {
show: false,
},
},
tooltip: {
enabled: true,
x: {
show: false,
},
},
fill: {
type: "gradient",
gradient: {
opacityFrom: 0.55,
opacityTo: 0,
shade: "#1C64F2",
gradientToColors: ["#1C64F2"],
},
},
dataLabels: {
enabled: false,
},
stroke: {
width: 6,
},
grid: {
show: false,
strokeDashArray: 4,
padding: {
left: 2,
right: 2,
top: -26
},
},
series: [
{
name: "Listed Price",
data: [],
color: "#1A56DB",
},
{
name: "Unlisted Price",
data: [],
color: "#7E3BF2",
},
],
xaxis: {
categories: [],
labels: {
show: false,
},
axisBorder: {
show: false,
},
axisTicks: {
show: false,
},
},
yaxis: {
show: false,
labels: {
formatter: function (value) {
return '$' + value;
}
}
},
}
onMounted(async () => {
// Fetch data from the API
// and set it to the chartData
try {
const resp = await fetch('/api/dashboard/');
if (resp.status === 401) {
// user will be redirected to login page automatically so no need to handle anything here
return;
}
data.value = await resp.json();
} catch (error) {
window.adminforth.alert({
message: `Error fetching data: ${error.message}`,
variant: 'danger',
timeout: 'unlimited'
});
}
const apartsByDaysReverse = data.value.apartsByDays.reverse();
optionsC1.series[0].data = apartsByDaysReverse.map((item) => item.count);
optionsC1.xaxis.categories = apartsByDaysReverse.map((item) => dayjs(item.day).format('DD MMM'));
const chart = new ApexCharts(document.getElementById("area-chart"), optionsC1);
chart.render();
optionsC2.series[0].data = data.value.listedVsUnlistedByDays.map((item) => item.listed);
optionsC2.series[1].data = data.value.listedVsUnlistedByDays.map((item) => item.unlisted);
optionsC2.xaxis.categories = data.value.listedVsUnlistedByDays.map((item) => dayjs(item.day).format('DD MMM'));
const chart2 = new ApexCharts(document.getElementById("bar-chart"), optionsC2);
chart2.render();
optionsC3.series[0].data = data.value.listedVsUnlistedPriceByDays.map((item) => item.listedPrice.toFixed(2));
optionsC3.series[1].data = data.value.listedVsUnlistedPriceByDays.map((item) => item.unlistedPrice.toFixed(2));
optionsC3.xaxis.categories = data.value.listedVsUnlistedPriceByDays.map((item) => dayjs(item.day).format('DD MMM'));
const chart3 = new ApexCharts(document.getElementById("size-chart"), optionsC3);
chart3.render();
})
</script><template>
<div class="px-4 py-8 bg-blue-50 dark:bg-gray-900 dark:shadow-none h-screen">
<h1 class="mb-4 text-xl font-extrabold text-gray-900 dark:text-white md:text-2xl lg:text-3xl"><span
class="text-transparent bg-clip-text bg-gradient-to-r to-emerald-600 from-sky-400">Appartments</span>
Statistics.</h1>
<div class="grid grid-cols-3 gap-4">
<div class="max-w-md w-full bg-white rounded-lg shadow dark:bg-gray-800 p-4 md:p-6" v-if="data">
<div class="flex justify-between">
<div>
<h5 class="leading-none text-3xl font-bold text-gray-900 dark:text-white pb-2">{{ data.totalAparts }}</h5>
<p class="text-base font-normal text-gray-500 dark:text-gray-400">Apartments last 7 days</p>
</div>
</div>
<div id="area-chart"></div>
</div>
<div class="w-full bg-white rounded-lg shadow dark:bg-gray-800 p-4 md:p-6 row-span-2 col-span-2" v-if="data">
<div class="grid grid-cols-2 py-3">
<dl>
<dt class="text-base font-normal text-gray-500 dark:text-gray-400 pb-1">Listed price</dt>
<dd class="leading-none text-xl font-bold text-green-500 dark:text-green-400">{{
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(
data.totalListedPrice,
) }}
</dd>
</dl>
<dl>
<dt class="text-base font-normal text-gray-500 dark:text-gray-400 pb-1">Unlisted price</dt>
<dd class="leading-none text-xl font-bold text-red-600 dark:text-red-500">{{
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(
data.totalUnlistedPrice,
) }}
</dd>
</dl>
</div>
<div id="bar-chart"></div>
</div>
<div class="max-w-md w-full bg-white rounded-lg shadow dark:bg-gray-800 p-4 md:p-6" v-if="data">
<div class="flex justify-between mb-5">
<div>
<p class="text-base font-normal text-gray-500 dark:text-gray-400">
Unlisted vs Listed price
</p>
</div>
</div>
<div id="size-chart" class="[&>div]:mx-auto"></div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import ApexCharts from 'apexcharts';
import dayjs from 'dayjs';
import { callApi } from '@/utils';
const data = ref({});
const optionsC1 = {
chart: {
height: 145,
type: "area",
fontFamily: "Inter, sans-serif",
dropShadow: {
enabled: false,
},
toolbar: {
show: false,
},
},
tooltip: {
enabled: true,
x: {
show: false,
},
},
fill: {
type: "gradient",
gradient: {
opacityFrom: 0.55,
opacityTo: 0,
shade: "#1C64F2",
gradientToColors: ["#1C64F2"],
},
},
dataLabels: {
enabled: false,
},
stroke: {
width: 6,
},
grid: {
show: false,
strokeDashArray: 4,
padding: {
left: 2,
right: 2,
top: 0
},
},
series: [
{
name: "Added appartments",
data: [],
color: "#1A56DB",
},
],
xaxis: {
categories: [],
labels: {
show: false,
},
axisBorder: {
show: false,
},
axisTicks: {
show: false,
},
},
yaxis: {
show: false,
},
};
const optionsC2 = {
series: [
{
name: "Listed",
color: "#31C48D",
data: [],
},
{
name: "Unlisted",
data: [],
color: "#F05252",
}
],
chart: {
sparkline: {
enabled: false,
},
type: "bar",
width: "100%",
height: 400,
toolbar: {
show: false,
}
},
fill: {
opacity: 1,
},
plotOptions: {
bar: {
horizontal: true,
columnWidth: "100%",
borderRadiusApplication: "end",
borderRadius: 6,
dataLabels: {
position: "top",
},
},
},
legend: {
show: true,
position: "bottom",
},
dataLabels: {
enabled: false,
},
tooltip: {
shared: true,
intersect: false,
formatter: function (value) {
return value
},
},
xaxis: {
labels: {
show: true,
style: {
fontFamily: "Inter, sans-serif",
cssClass: 'text-xs font-normal fill-gray-500 dark:fill-gray-400'
},
formatter: function (value) {
return value
}
},
categories: [],
axisTicks: {
show: false,
},
axisBorder: {
show: false,
},
},
yaxis: {
labels: {
show: true,
style: {
fontFamily: "Inter, sans-serif",
cssClass: 'text-xs font-normal fill-gray-500 dark:fill-gray-400'
}
}
},
grid: {
show: true,
strokeDashArray: 4,
padding: {
left: 10,
right: 2,
// top: -20
},
},
fill: {
opacity: 1,
}
}
const optionsC3 = {
chart: {
height: 145,
type: "area",
fontFamily: "Inter, sans-serif",
dropShadow: {
enabled: false,
},
toolbar: {
show: false,
},
},
tooltip: {
enabled: true,
x: {
show: false,
},
},
fill: {
type: "gradient",
gradient: {
opacityFrom: 0.55,
opacityTo: 0,
shade: "#1C64F2",
gradientToColors: ["#1C64F2"],
},
},
dataLabels: {
enabled: false,
},
stroke: {
width: 6,
},
grid: {
show: false,
strokeDashArray: 4,
padding: {
left: 2,
right: 2,
top: -26
},
},
series: [
{
name: "Listed Price",
data: [],
color: "#1A56DB",
},
{
name: "Unlisted Price",
data: [],
color: "#7E3BF2",
},
],
xaxis: {
categories: [],
labels: {
show: false,
},
axisBorder: {
show: false,
},
axisTicks: {
show: false,
},
},
yaxis: {
show: false,
labels: {
formatter: function (value) {
return '$' + value;
}
}
},
}
onMounted(async () => {
// Fetch data from the API
// and set it to the chartData
try {
data.value = await callApi({path: '/api/dashboard/', method: 'GET'});
} catch (error) {
window.adminforth.alert({
message: `Error fetching data: ${error.message}`,
variant: 'danger',
timeout: 'unlimited'
});
}
const apartsByDaysReverse = data.value.apartsByDays.reverse();
optionsC1.series[0].data = apartsByDaysReverse.map((item) => item.count);
optionsC1.xaxis.categories = apartsByDaysReverse.map((item) => dayjs(item.day).format('DD MMM'));
const chart = new ApexCharts(document.getElementById("area-chart"), optionsC1);
chart.render();
optionsC2.series[0].data = data.value.listedVsUnlistedByDays.map((item) => item.listed);
optionsC2.series[1].data = data.value.listedVsUnlistedByDays.map((item) => item.unlisted);
optionsC2.xaxis.categories = data.value.listedVsUnlistedByDays.map((item) => dayjs(item.day).format('DD MMM'));
const chart2 = new ApexCharts(document.getElementById("bar-chart"), optionsC2);
chart2.render();
optionsC3.series[0].data = data.value.listedVsUnlistedPriceByDays.map((item) => item.listedPrice.toFixed(2));
optionsC3.series[1].data = data.value.listedVsUnlistedPriceByDays.map((item) => item.unlistedPrice.toFixed(2));
optionsC3.xaxis.categories = data.value.listedVsUnlistedPriceByDays.map((item) => dayjs(item.day).format('DD MMM'));
const chart3 = new ApexCharts(document.getElementById("size-chart"), optionsC3);
chart3.render();
})
</script>
@@ -37,7 +37,7 @@
Now we have to define this endpoint in the backend to make our page work:
Open index.ts
file and add the following code BEFORE admin.express.serve(
!
....
app.get('/api/dashboard/',
admin.express.authorize(
async (req, res) => {
const days = req.body.days || 7;
const apartsByDays = await db.prepare(
`SELECT
strftime('%Y-%m-%d', created_at, 'unixepoch') as day,
COUNT(*) as count
FROM apartments
GROUP BY day
ORDER BY day DESC
LIMIT ?;
`
).all(days);
const totalAparts = apartsByDays.reduce((acc, { count }) => acc + count, 0);
// add listed, unlisted, listedPrice, unlistedPrice
const listedVsUnlistedByDays = await db.prepare(
`SELECT
strftime('%Y-%m-%d', created_at, 'unixepoch') as day,
SUM(listed) as listed,
COUNT(*) - SUM(listed) as unlisted,
SUM(listed * price) as listedPrice,
SUM((1 - listed) * price) as unlistedPrice
FROM apartments
GROUP BY day
ORDER BY day DESC
LIMIT ?;
`
).all(days);
const listedVsUnlistedPriceByDays = await db.prepare(
`SELECT
strftime('%Y-%m-%d', created_at, 'unixepoch') as day,
SUM(listed * price) as listedPrice,
SUM((1 - listed) * price) as unlistedPrice
FROM apartments
GROUP BY day
ORDER BY day DESC
LIMIT ?;
`
).all(days);
const totalListedPrice = Math.round(listedVsUnlistedByDays.reduce((acc, { listedPrice }) => acc + listedPrice, 0));
const totalUnlistedPrice = Math.round(listedVsUnlistedByDays.reduce((acc, { unlistedPrice }) => acc + unlistedPrice, 0));
res.json({
apartsByDays,
totalAparts,
listedVsUnlistedByDays,
totalListedPrice,
totalUnlistedPrice,
listedVsUnlistedPriceByDays,
});
}
)
);
// serve after you added all api
admin.express.serve(app, express)
admin.discoverDatabases();
....
app.get(`${ADMIN_BASE_URL}/api/dashboard/`,
admin.express.authorize(
async (req, res) => {
const days = req.body.days || 7;
const apartsByDays = await db.prepare(
`SELECT
strftime('%Y-%m-%d', created_at, 'unixepoch') as day,
COUNT(*) as count
FROM apartments
GROUP BY day
ORDER BY day DESC
LIMIT ?;
`
).all(days);
const totalAparts = apartsByDays.reduce((acc, { count }) => acc + count, 0);
// add listed, unlisted, listedPrice, unlistedPrice
const listedVsUnlistedByDays = await db.prepare(
`SELECT
strftime('%Y-%m-%d', created_at, 'unixepoch') as day,
SUM(listed) as listed,
COUNT(*) - SUM(listed) as unlisted,
SUM(listed * price) as listedPrice,
SUM((1 - listed) * price) as unlistedPrice
FROM apartments
GROUP BY day
ORDER BY day DESC
LIMIT ?;
`
).all(days);
const listedVsUnlistedPriceByDays = await db.prepare(
`SELECT
strftime('%Y-%m-%d', created_at, 'unixepoch') as day,
SUM(listed * price) as listedPrice,
SUM((1 - listed) * price) as unlistedPrice
FROM apartments
GROUP BY day
ORDER BY day DESC
LIMIT ?;
`
).all(days);
const totalListedPrice = Math.round(listedVsUnlistedByDays.reduce((acc, { listedPrice }) => acc + listedPrice, 0));
const totalUnlistedPrice = Math.round(listedVsUnlistedByDays.reduce((acc, { unlistedPrice }) => acc + unlistedPrice, 0));
res.json({
apartsByDays,
totalAparts,
listedVsUnlistedByDays,
totalListedPrice,
totalUnlistedPrice,
listedVsUnlistedPriceByDays,
});
}
)
);
// serve after you added all api
admin.express.serve(app, express)
admin.discoverDatabases();
@@ -48,6 +48,12 @@🫨 Please note that we are using
admin.express.authorize
middleware to check if the user is logged in. If you want to make this endpoint public, you can remove this middleware. If user is not logged in, the request will return 401 Unauthorized status code, and protect our statistics from leak.
data.value = await callApi({path: '/api/dashboard/', method: 'GET'});
const response = await fetch('/api/dashboard/');
data.value = await response.json();
however, the callApi function will handle path prefixing(you can change baseUrl and it will take this into account) and 401 redirect to login when user is logged out
+Demo:
For example let's add a custom pie chart to the list
page of the aparts
resource. Pie chart will show the distribution of the rooms count and more over will allow to filter the list by the rooms count.
{
resourceId: 'aparts',
...
options: {
pageInjections: {
list: {
afterBreadcrumbs: '@@/ApartsPie.vue',
}
}
}
}
Now create file ApartsPie.vue
in the custom
folder of your project:
<template>
<div class="max-w-sm w-full bg-white rounded-lg shadow dark:bg-gray-800 p-4 md:p-4 mb-5">
<div id="pie-chart"></div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref, Ref } from 'vue';
import ApexCharts from 'apexcharts';
const data: Ref<any[]> = ref([]);
const POSSIBLE_COLORS = ["#1C64F2", "#16BDCA", "#9061F9", "#F0A936", "#F55252", "#3B82F6", "#10B981", "#F472B6", "#6B7280"];
const chatOptions = {
series: [],
colors: POSSIBLE_COLORS,
chart: {
height: 200,
width: "100%",
type: "pie",
events: {
dataPointSelection: function (event, chartContext, config) {
if (config.selectedDataPoints[0].length) {
const selectedRoomsCount = data.value[config.dataPointIndex].rooms;
window.adminforth.updateListFilter({field: 'number_of_rooms', operator: 'eq', value: selectedRoomsCount});
} else {
// clear filter
window.adminforth.updateListFilter({field: 'number_of_rooms', value: undefined});
}
}
},
},
stroke: {
colors: ["white"],
lineCap: "",
},
plotOptions: {
pie: {
labels: {
show: true,
},
size: "100%",
dataLabels: {
offset: -25
}
},
},
labels: ["Direct", "Organic search", "Referrals"],
dataLabels: {
enabled: true,
style: {
fontFamily: "Inter, sans-serif",
},
},
legend: {
position: "right",
fontFamily: "Inter, sans-serif",
},
yaxis: {
labels: {
formatter: function (value) {
return value + "%"
},
},
},
xaxis: {
labels: {
formatter: function (value) {
return value + "%"
},
},
axisTicks: {
show: false,
},
axisBorder: {
show: false,
},
},
}
onMounted(async () => {
try {
const resp = await fetch('/api/aparts-by-room-percentages');
if (resp.status === 401) {
// user will be redirected to login page automatically so no need to handle anything here
return;
}
data.value = await resp.json();
} catch (error) {
window.adminforth.alert({
message: `Error fetching data: ${error.message}`,
variant: 'danger',
timeout: 'unlimited'
});
return;
}
chatOptions.series = data.value.map((item) => item.percentage);
chatOptions.labels = data.value.map((item) => `${item.rooms} rooms`);
const chart = new ApexCharts(document.getElementById("pie-chart"), chatOptions);
chart.render();
})
</script>
<template>
<div class="max-w-sm w-full bg-white rounded-lg shadow dark:bg-gray-800 p-4 md:p-4 mb-5">
<div id="pie-chart"></div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref, Ref } from 'vue';
import ApexCharts from 'apexcharts';
import { callApi } from '@/utils';
const data: Ref<any[]> = ref([]);
const POSSIBLE_COLORS = ["#1C64F2", "#16BDCA", "#9061F9", "#F0A936", "#F55252", "#3B82F6", "#10B981", "#F472B6", "#6B7280"];
const chatOptions = {
series: [],
colors: POSSIBLE_COLORS,
chart: {
height: 200,
width: "100%",
type: "pie",
events: {
dataPointSelection: function (event, chartContext, config) {
if (config.selectedDataPoints[0].length) {
const selectedRoomsCount = data.value[config.dataPointIndex].rooms;
window.adminforth.updateListFilter({field: 'number_of_rooms', operator: 'eq', value: selectedRoomsCount});
} else {
// clear filter
window.adminforth.updateListFilter({field: 'number_of_rooms', value: undefined});
}
}
},
},
stroke: {
colors: ["white"],
lineCap: "",
},
plotOptions: {
pie: {
labels: {
show: true,
},
size: "100%",
dataLabels: {
offset: -25
}
},
},
labels: ["Direct", "Organic search", "Referrals"],
dataLabels: {
enabled: true,
style: {
fontFamily: "Inter, sans-serif",
},
},
legend: {
position: "right",
fontFamily: "Inter, sans-serif",
},
yaxis: {
labels: {
formatter: function (value) {
return value + "%"
},
},
},
xaxis: {
labels: {
formatter: function (value) {
return value + "%"
},
},
axisTicks: {
show: false,
},
axisBorder: {
show: false,
},
},
}
onMounted(async () => {
try {
data.value = await callApi({path: '/api/aparts-by-room-percentages', method: 'GET'});
} catch (error) {
window.adminforth.alert({
message: `Error fetching data: ${error.message}`,
variant: 'danger',
timeout: 'unlimited'
});
return;
}
chatOptions.series = data.value.map((item) => item.percentage);
chatOptions.labels = data.value.map((item) => `${item.rooms} rooms`);
const chart = new ApexCharts(document.getElementById("pie-chart"), chatOptions);
chart.render();
})
</script>
Also we have to add an Api to get percentages:
app.get('/api/aparts-by-room-percentages/',
admin.express.authorize(
async (req, res) => {
const roomPercentages = await db.prepare(
`SELECT
number_of_rooms,
COUNT(*) as count
FROM apartments
GROUP BY number_of_rooms
ORDER BY number_of_rooms;
`
).all();
const totalAparts = roomPercentages.reduce((acc, { count }) => acc + count, 0);
res.json(
roomPercentages.map(
({ number_of_rooms, count }) => ({
rooms: number_of_rooms,
percentage: Math.round(count / totalAparts * 100),
})
)
);
}
)
);
// serve after you added all api
admin.discoverDatabases();
admin.express.serve(app)
diff --git a/docs/tutorial/Customization/virtualColumns/index.html b/docs/tutorial/Customization/virtualColumns/index.html index f16ac7637..a8ddd198c 100644 --- a/docs/tutorial/Customization/virtualColumns/index.html +++ b/docs/tutorial/Customization/virtualColumns/index.html @@ -13,7 +13,7 @@ - + diff --git a/docs/tutorial/Plugins/AuditLog/index.html b/docs/tutorial/Plugins/AuditLog/index.html index fe5e263a2..b1646443a 100644 --- a/docs/tutorial/Plugins/AuditLog/index.html +++ b/docs/tutorial/Plugins/AuditLog/index.html @@ -13,7 +13,7 @@ - + diff --git a/docs/tutorial/Plugins/ForeignInlineList/index.html b/docs/tutorial/Plugins/ForeignInlineList/index.html index f324146e6..1a7eb09cc 100644 --- a/docs/tutorial/Plugins/ForeignInlineList/index.html +++ b/docs/tutorial/Plugins/ForeignInlineList/index.html @@ -13,7 +13,7 @@ - + diff --git a/docs/tutorial/Plugins/QuillEditor/index.html b/docs/tutorial/Plugins/QuillEditor/index.html index 66d5bc6f2..14baeb829 100644 --- a/docs/tutorial/Plugins/QuillEditor/index.html +++ b/docs/tutorial/Plugins/QuillEditor/index.html @@ -13,7 +13,7 @@ - + diff --git a/docs/tutorial/Plugins/TwoFactorsAuth/index.html b/docs/tutorial/Plugins/TwoFactorsAuth/index.html index 474e4bcf7..78eac4c01 100644 --- a/docs/tutorial/Plugins/TwoFactorsAuth/index.html +++ b/docs/tutorial/Plugins/TwoFactorsAuth/index.html @@ -13,7 +13,7 @@ - + diff --git a/docs/tutorial/Plugins/s3Upload/index.html b/docs/tutorial/Plugins/s3Upload/index.html index f471681ec..2b8f187f6 100644 --- a/docs/tutorial/Plugins/s3Upload/index.html +++ b/docs/tutorial/Plugins/s3Upload/index.html @@ -13,7 +13,7 @@ - + diff --git a/docs/tutorial/deploy/index.html b/docs/tutorial/deploy/index.html index d7f07e201..169e54a65 100644 --- a/docs/tutorial/deploy/index.html +++ b/docs/tutorial/deploy/index.html @@ -13,7 +13,7 @@ - + @@ -51,6 +51,10 @@
./compose.ymlversion: '3.8'
services:
traefik:
image: "traefik:v2.5"
command:
- "--api.insecure=true"
- "--providers.docker=true"
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
- "--certificatesresolvers.myresolver.acme.httpchallenge=true"
- "--certificatesresolvers.myresolver.acme.httpchallenge.entrypoint=web"
- "--certificatesresolvers.myresolver.acme.email=demo@devforth.io" # ⚠️ replace with your email
- "--certificatesresolvers.myresolver.acme.storage=/letsencrypt/acme.json"
ports:
- "80:80"
- "443:443"
volumes:
- "/var/run/docker.sock:/var/run/docker.sock:ro"
- "./letsencrypt:/letsencrypt"
labels:
- "traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https"
- "traefik.http.routers.http-catchall.rule=hostregexp(`{host:.+}`)"
- "traefik.http.routers.http-catchall.entrypoints=web"
- "traefik.http.routers.http-catchall.middlewares=redirect-to-https"
- "traefik.http.routers.http-catchall.tls=false"
adminforth:
build: ./app
environment:
- NODE_ENV=production
- ADMINFORTH_SECRET=!CHANGEME! # ⚠️ replace with your secret
labels:
- "traefik.enable=true"
- "traefik.http.routers.adminforth.tls=true"
- "traefik.http.routers.adminforth.tls.certresolver=myresolver"
- "traefik.http.routers.adminforth.rule=PathPrefix(`/`)"
- "traefik.http.services.adminforth.loadbalancer.server.port=3500"
- "traefik.http.routers.adminforth.priority=1"
networks:
default:
driver: bridgeNow pull this compose file and all directories to your server and run:
+docker compose -p stack-my-app -f compose.yml up -d --build --remove-orphans --wait
+🫨 You can also test this compose stack locally on your machine but SSL will nto work, +so locally you can ignore Chrome warning about SSL and test your AdminForth application.
+Subpath deployment
If you want to deploy your AdminForth application to a sub-folder like
diff --git a/docs/tutorial/gettingStarted/index.html b/docs/tutorial/gettingStarted/index.html index 23bd1f7b3..8036e29c4 100644 --- a/docs/tutorial/gettingStarted/index.html +++ b/docs/tutorial/gettingStarted/index.html @@ -13,7 +13,7 @@ - + diff --git a/docs/tutorial/glossary/index.html b/docs/tutorial/glossary/index.html index a70b6fbcc..f79585f3f 100644 --- a/docs/tutorial/glossary/index.html +++ b/docs/tutorial/glossary/index.html @@ -13,7 +13,7 @@ - + diff --git a/index.html b/index.html index 387766d38..1a950025b 100644 --- a/index.html +++ b/index.html @@ -13,7 +13,7 @@ - + diff --git a/markdown-page/index.html b/markdown-page/index.html index 475e1197a..4c18900eb 100644 --- a/markdown-page/index.html +++ b/markdown-page/index.html @@ -13,7 +13,7 @@ - + diff --git a/search/index.html b/search/index.html index d81d1867d..a244b4e99 100644 --- a/search/index.html +++ b/search/index.html @@ -13,7 +13,7 @@ - +https://mydomain.com/admin
you should do the following: