1
0
Fork 0
mirror of https://github.com/mealie-recipes/mealie.git synced 2025-08-02 20:15:24 +02:00

feat: admin maintenance and analytics stubs (#1107)

* add tail log viewer routes

* add log viewer

* add _mealie to ignore directories

* add detailed breakdown of storage

* generate types

* add dialog to view breakdown

* cleanup mobile UI

* move migrations page

* spelling

* init analytics page

* move route up

* add remove temp files function

* analytics API client

* stub out analytics pages

* generate types

* stub out analytics routes

* update names

* ignore types

* temporary remove analytics from sidebar
This commit is contained in:
Hayden 2022-03-29 08:25:28 -08:00 committed by GitHub
parent 6f309d7a89
commit 1a23f867da
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 536 additions and 59 deletions

View file

@ -4,6 +4,7 @@ import { AdminUsersApi } from "./admin/admin-users";
import { AdminGroupsApi } from "./admin/admin-groups";
import { AdminBackupsApi } from "./admin/admin-backups";
import { AdminMaintenanceApi } from "./admin/admin-maintenance";
import { AdminAnalyticsApi } from "./admin/admin-analytics";
import { ApiRequestInstance } from "~/types/api";
export class AdminAPI {
@ -13,6 +14,7 @@ export class AdminAPI {
public groups: AdminGroupsApi;
public backups: AdminBackupsApi;
public maintenance: AdminMaintenanceApi;
public analytics: AdminAnalyticsApi;
constructor(requests: ApiRequestInstance) {
this.about = new AdminAboutAPI(requests);
@ -21,6 +23,7 @@ export class AdminAPI {
this.groups = new AdminGroupsApi(requests);
this.backups = new AdminBackupsApi(requests);
this.maintenance = new AdminMaintenanceApi(requests);
this.analytics = new AdminAnalyticsApi(requests);
Object.freeze(this);
}

View file

@ -0,0 +1,14 @@
import { BaseAPI } from "../_base";
import { MealieAnalytics } from "~/types/api-types/analytics";
const prefix = "/api";
const routes = {
base: `${prefix}/admin/analytics`,
};
export class AdminAnalyticsApi extends BaseAPI {
async getAnalytics() {
return await this.requests.get<MealieAnalytics>(routes.base);
}
}

View file

@ -1,11 +1,14 @@
import { BaseAPI } from "../_base";
import { SuccessResponse } from "~/types/api-types/response";
import { MaintenanceSummary } from "~/types/api-types/admin";
import { MaintenanceLogs, MaintenanceStorageDetails, MaintenanceSummary } from "~/types/api-types/admin";
const prefix = "/api";
const routes = {
base: `${prefix}/admin/maintenance`,
storage: `${prefix}/admin/maintenance/storage`,
logs: (lines: number) => `${prefix}/admin/maintenance/logs?lines=${lines}`,
cleanTemp: `${prefix}/admin/maintenance/clean/temp`,
cleanImages: `${prefix}/admin/maintenance/clean/images`,
cleanRecipeFolders: `${prefix}/admin/maintenance/clean/recipe-folders`,
cleanLogFile: `${prefix}/admin/maintenance/clean/logs`,
@ -16,6 +19,14 @@ export class AdminMaintenanceApi extends BaseAPI {
return this.requests.get<MaintenanceSummary>(routes.base);
}
async getStorageDetails() {
return await this.requests.get<MaintenanceStorageDetails>(routes.storage);
}
async cleanTemp() {
return await this.requests.post<SuccessResponse>(routes.cleanTemp, {});
}
async cleanImages() {
return await this.requests.post<SuccessResponse>(routes.cleanImages, {});
}
@ -27,4 +38,8 @@ export class AdminMaintenanceApi extends BaseAPI {
async cleanLogFile() {
return await this.requests.post<SuccessResponse>(routes.cleanLogFile, {});
}
async logs(lines: number) {
return await this.requests.get<MaintenanceLogs>(routes.logs(lines));
}
}

View file

@ -44,21 +44,21 @@ export default defineComponent({
});
const topLinks: SidebarLinks = [
// {
// icon: $globals.icons.viewDashboard,
// to: "/admin/dashboard",
// title: i18n.t("sidebar.dashboard"),
// },
{
icon: $globals.icons.cog,
to: "/admin/site-settings",
title: i18n.t("sidebar.site-settings"),
},
{
icon: $globals.icons.cog,
icon: $globals.icons.wrench,
to: "/admin/maintenance",
title: "Maintenance",
},
// {
// icon: $globals.icons.chart,
// to: "/admin/analytics",
// title: "Analytics",
// },
{
icon: $globals.icons.user,
to: "/admin/manage/users",

View file

@ -0,0 +1,137 @@
<template>
<v-container fluid class="md-container">
<BannerExperimental></BannerExperimental>
<BaseCardSectionTitle title="Site Analytics">
Your instance of Mealie can send anonymous usage statistics to the Mealie project team. This is done to help us
gauge the usage of mealie, provide public statistics and to help us improve the user experience.
<p class="pt-4 pb-0 mb-0">
Your installation creates a UUID that is used to identify your installation,
<strong> this is randomly generated using the UUID4 implementation in python</strong>. This UUID is stored on
our analytics server and used to ensure your data is only counted once.
</p>
</BaseCardSectionTitle>
<section>
<BaseCardSectionTitle class="pb-0" :icon="$globals.icons.chart" title="Analytics Settings">
When you opt into analytics your install will register itself with the Analytics API to count the installation
and register your generated anonymous installation ID
</BaseCardSectionTitle>
<v-card-text>
<v-switch v-model="state.analyticsEnabled" label="Collect Anonymous Analytics" />
</v-card-text>
</section>
<section class="my-8">
<BaseCardSectionTitle class="pb-0" :icon="$globals.icons.chart" title="Analytics Data">
This is a list of all the data that is sent to the Mealie project team.
</BaseCardSectionTitle>
<v-card class="ma-2">
<template v-for="(value, idx) in data">
<v-list-item :key="`item-${idx}`">
<v-list-item-title class="py-2">
<div>{{ value.text }}</div>
<v-list-item-subtitle class="text-end"> {{ getValue(value.valueKey) }} </v-list-item-subtitle>
</v-list-item-title>
</v-list-item>
<v-divider :key="`divider-${idx}`" class="mx-2"></v-divider>
</template>
</v-card>
</section>
</v-container>
</template>
<script lang="ts">
import { defineComponent, reactive, useAsync } from "@nuxtjs/composition-api";
import { useAdminApi } from "~/composables/api";
import { useAsyncKey } from "~/composables/use-utils";
import { MealieAnalytics } from "~/types/api-types/analytics";
type DisplayData = {
text: string;
valueKey: keyof MealieAnalytics;
};
export default defineComponent({
layout: "admin",
setup() {
const adminApi = useAdminApi();
const state = reactive({
analyticsEnabled: false,
});
const analyticsData = useAsync(async () => {
const { data } = await adminApi.analytics.getAnalytics();
return data;
}, useAsyncKey());
function getValue(key: keyof MealieAnalytics) {
if (!analyticsData.value) {
return "";
}
return analyticsData.value[key];
}
const data: DisplayData[] = [
{
text: "Installation Id",
valueKey: "installationId",
},
{
text: "Version",
valueKey: "version",
},
{
text: "Database",
valueKey: "databaseType",
},
{
text: "Using Email",
valueKey: "usingEmail",
},
{
text: "Using LDAP",
valueKey: "usingLdap",
},
{
text: "API Tokens",
valueKey: "apiTokens",
},
{
text: "Users",
valueKey: "users",
},
{
text: "Recipes",
valueKey: "recipes",
},
{
text: "Groups",
valueKey: "groups",
},
{
text: "Shopping Lists",
valueKey: "shoppingLists",
},
{
text: "Cookbooks",
valueKey: "cookbooks",
},
];
return {
data,
state,
analyticsData,
getValue,
};
},
head() {
return {
title: "Analytics",
};
},
});
</script>
<style scoped></style>

View file

@ -1,51 +1,69 @@
<template>
<v-container fluid class="narrow-container">
<BaseDialog v-model="state.storageDetails" title="Storage Details" :icon="$globals.icons.folderOutline">
<div class="py-2">
<template v-for="(value, key, idx) in storageDetails">
<v-list-item :key="`item-${key}`">
<v-list-item-title>
<div>{{ storageDetailsText(key) }}</div>
</v-list-item-title>
<v-list-item-subtitle class="text-end"> {{ value }} </v-list-item-subtitle>
</v-list-item>
<v-divider v-if="idx != 4" :key="`divider-${key}`" class="mx-2"></v-divider>
</template>
</div>
</BaseDialog>
<BasePageTitle divider>
<template #title> Site Maintenance </template>
</BasePageTitle>
<BannerExperimental />
<div class="d-flex justify-end">
<ButtonLink to="/admin/maintenance/logs" text="Logs" :icon="$globals.icons.file" />
</div>
<section>
<BaseCardSectionTitle class="pb-0" :icon="$globals.icons.cog" title="Summary"> </BaseCardSectionTitle>
<div class="mb-6 ml-2">
<BaseCardSectionTitle class="pb-0" :icon="$globals.icons.wrench" title="Summary"> </BaseCardSectionTitle>
<div class="mb-6 ml-2 d-flex" style="gap: 0.3rem">
<BaseButton color="info" @click="getSummary">
<template #icon> {{ $globals.icons.tools }} </template>
Get Summary
</BaseButton>
<BaseButton color="info" @click="openDetails">
<template #icon> {{ $globals.icons.folderOutline }} </template>
Details
</BaseButton>
</div>
<v-card class="ma-2" :loading="state.fetchingInfo">
<template v-for="(value, idx) in info">
<v-list-item :key="`item-${idx}`">
<v-list-item-title>
<v-list-item-title class="py-2">
<div>{{ value.name }}</div>
<v-list-item-subtitle class="text-end"> {{ value.value }} </v-list-item-subtitle>
</v-list-item-title>
<v-list-item-subtitle class="text-end"> {{ value.value }} </v-list-item-subtitle>
</v-list-item>
<v-divider :key="`divider-${idx}`" class="mx-2"></v-divider>
</template>
</v-card>
</section>
<section>
<BaseCardSectionTitle class="pb-0 mt-8" :icon="$globals.icons.cog" title="Actions">
<BaseCardSectionTitle class="pb-0 mt-8" :icon="$globals.icons.wrench" title="Actions">
Maintenance actions are <b> destructive </b> and should be used with caution. Performing any of these actions is
<b> irreversible </b>.
</BaseCardSectionTitle>
<v-card class="ma-2" :loading="state.actionLoading">
<template v-for="(action, idx) in actions">
<v-list-item :key="`item-${idx}`">
<v-list-item :key="`item-${idx}`" class="py-1">
<v-list-item-title>
<div>{{ action.name }}</div>
<v-list-item-subtitle>
<v-list-item-subtitle class="wrap-word">
{{ action.subtitle }}
</v-list-item-subtitle>
</v-list-item-title>
<v-list-item-action>
<BaseButton color="info" @click="action.handler">
<template #icon> {{ $globals.icons.robot }}</template>
Run
</BaseButton>
</v-list-item-action>
<BaseButton color="info" @click="action.handler">
<template #icon> {{ $globals.icons.robot }}</template>
Run
</BaseButton>
</v-list-item>
<v-divider :key="`divider-${idx}`" class="mx-2"></v-divider>
</template>
@ -57,18 +75,23 @@
<script lang="ts">
import { computed, ref, defineComponent, reactive } from "@nuxtjs/composition-api";
import { useAdminApi } from "~/composables/api";
import { MaintenanceSummary } from "~/types/api-types/admin";
import { MaintenanceStorageDetails, MaintenanceSummary } from "~/types/api-types/admin";
export default defineComponent({
layout: "admin",
setup() {
const state = reactive({
storageDetails: false,
storageDetailsLoading: false,
fetchingInfo: false,
actionLoading: false,
});
const adminApi = useAdminApi();
// ==========================================================================
// General Info
const infoResults = ref<MaintenanceSummary>({
dataDirSize: "unknown",
logFileSize: "unknown",
@ -111,6 +134,39 @@ export default defineComponent({
];
});
// ==========================================================================
// Storage Details
const storageTitles: { [key: string]: string } = {
tempDirSize: "Temporary Directory (.temp)",
backupsDirSize: "Backups Directory (backups)",
groupsDirSize: "Groups Directory (groups)",
recipesDirSize: "Recipes Directory (recipes)",
userDirSize: "User Directory (user)",
};
function storageDetailsText(key: string) {
return storageTitles[key] ?? "unknown";
}
const storageDetails = ref<MaintenanceStorageDetails | null>(null);
async function openDetails() {
state.storageDetailsLoading = true;
state.storageDetails = true;
const { data } = await adminApi.maintenance.getStorageDetails();
if (data) {
storageDetails.value = data;
}
state.storageDetailsLoading = true;
}
// ==========================================================================
// Actions
async function handleDeleteLogFile() {
state.actionLoading = true;
await adminApi.maintenance.cleanLogFile();
@ -129,6 +185,12 @@ export default defineComponent({
state.actionLoading = false;
}
async function handleCleanTemp() {
state.actionLoading = true;
await adminApi.maintenance.cleanTemp();
state.actionLoading = false;
}
const actions = [
{
name: "Delete Log Files",
@ -140,6 +202,11 @@ export default defineComponent({
handler: handleCleanDirectories,
subtitle: "Removes all the recipe folders that are not valid UUIDs",
},
{
name: "Clean Temporary Files",
handler: handleCleanTemp,
subtitle: "Removes all files and folders in the .temp directory",
},
{
name: "Clean Images",
handler: handleCleanImages,
@ -148,6 +215,9 @@ export default defineComponent({
];
return {
storageDetailsText,
openDetails,
storageDetails,
state,
info,
getSummary,
@ -162,4 +232,9 @@ export default defineComponent({
});
</script>
<style scoped></style>
<style scoped>
.wrap-word {
white-space: normal;
word-wrap: break-word;
}
</style>

View file

@ -0,0 +1,100 @@
<template>
<v-container fluid>
<BaseCardSectionTitle class="pb-0" :icon="$globals.icons.cog" title="Summary"> </BaseCardSectionTitle>
<div class="mb-6 ml-2 d-flex" style="gap: 0.8rem">
<BaseButton color="info" :loading="state.loading" @click="refreshLogs">
<template #icon> {{ $globals.icons.refreshCircle }} </template>
Refresh Logs
</BaseButton>
<AppButtonCopy :copy-text="copyText" />
<div class="ml-auto" style="max-width: 150px">
<v-text-field v-model="state.lines" type="number" label="Tail Lines" hide-details dense outlined>
</v-text-field>
</div>
</div>
<v-card outlined>
<v-virtual-scroll
v-scroll="scrollOptions"
:bench="20"
:items="logs.logs"
height="800"
item-height="20"
class="keep-whitespace log-container"
>
<template #default="{ item }">
<p class="log-text">
{{ item }}
</p>
</template>
</v-virtual-scroll>
</v-card>
</v-container>
</template>
<script lang="ts">
import { defineComponent, ref } from "@nuxtjs/composition-api";
import { computed, onMounted, reactive } from "vue-demi";
import { useAdminApi } from "~/composables/api";
export default defineComponent({
layout: "admin",
setup() {
const adminApi = useAdminApi();
const state = reactive({
loading: false,
lines: 500,
autoRefresh: true,
});
const scrollOptions = reactive({
enable: true,
always: false,
smooth: false,
notSmoothOnInit: true,
});
const logs = ref({
logs: [] as string[],
});
async function refreshLogs() {
state.loading = true;
const { data } = await adminApi.maintenance.logs(state.lines);
if (data) {
logs.value = data;
}
state.loading = false;
}
onMounted(() => {
refreshLogs();
});
const copyText = computed(() => {
return logs.value.logs.join("") || "";
});
return {
copyText,
scrollOptions,
state,
refreshLogs,
logs,
};
},
head: {
title: "Mealie Logs",
},
});
</script>
<style>
.log-text {
font: 0.8rem Inconsolata, monospace;
}
.log-container {
background-color: var(--v-background-base) !important;
}
.keep-whitespace {
white-space: pre;
}
</style>

View file

@ -1,29 +0,0 @@
<template>
<v-container fluid>
<BaseCardSectionTitle title="Data Migrations">
Lorem, ipsum dolor sit amet consectetur adipisicing elit. Alias error provident, eveniet, laboriosam assumenda
earum amet quaerat vel consequatur molestias sed enim. Adipisci a consequuntur dolor culpa expedita voluptatem
praesentium optio iste atque, ea reiciendis iure non aut suscipit modi ducimus ratione, quam numquam quaerat
distinctio illum nemo. Dicta, doloremque!
</BaseCardSectionTitle>
</v-container>
</template>
<script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api";
export default defineComponent({
layout: "admin",
setup() {
return {};
},
head() {
return {
title: this.$t("settings.migrations") as string,
};
},
});
</script>
<style scoped>
</style>

View file

@ -182,7 +182,7 @@
event: 'randomDinner',
},
{
icon: $globals.icons.bolwMixOutline,
icon: $globals.icons.bowlMixOutline,
text: 'Random Side',
event: 'randomSide',
},

View file

@ -190,6 +190,16 @@ export interface ImportJob {
force?: boolean;
rebase?: boolean;
}
export interface MaintenanceLogs {
logs: string[];
}
export interface MaintenanceStorageDetails {
tempDirSize: string;
backupsDirSize: string;
groupsDirSize: string;
recipesDirSize: string;
userDirSize: string;
}
export interface MaintenanceSummary {
dataDirSize: string;
logFileSize: string;

View file

@ -0,0 +1,20 @@
/* tslint:disable */
/* eslint-disable */
/**
/* This file was automatically generated from pydantic models by running pydantic2ts.
/* Do not modify it by hand - just update the pydantic models and then re-run the script
*/
export interface MealieAnalytics {
installationId: string;
version: string;
databaseType: string;
usingEmail: boolean;
usingLdap: boolean;
apiTokens: number;
users: number;
groups: number;
recipes: number;
shoppingLists: number;
cookbooks: number;
}

View file

@ -3,7 +3,9 @@ export interface Icon {
primary: string;
// General
bolwMixOutline: string;
chart: string;
wrench: string;
bowlMixOutline: string;
foods: string;
units: string;
alert: string;

View file

@ -105,14 +105,19 @@ import {
mdiArrowRightBold,
mdiChevronRight,
mdiBowlMixOutline,
mdiWrench,
mdiChartLine,
} from "@mdi/js";
export const icons = {
// Primary
primary: mdiSilverwareVariant,
wrench: mdiWrench,
chart: mdiChartLine,
// General
bolwMixOutline: mdiBowlMixOutline,
bowlMixOutline: mdiBowlMixOutline,
foods: mdiFoodApple,
units: mdiBeakerOutline,
alert: mdiAlert,