feat: admin maintenance page (#1096)

* fix build typo

* generate types

* setup maintenance api for common cleanup actions

* admin maintenance page

* remove duplicate use-with-caution
This commit is contained in:
Hayden 2022-03-24 22:17:38 -08:00 committed by GitHub
parent ffb3b45ac2
commit 4ef649231b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 376 additions and 2 deletions

View File

@ -47,7 +47,7 @@ jobs:
docker build --push --no-cache \ docker build --push --no-cache \
--tag hkotel/mealie:api-nightly \ --tag hkotel/mealie:api-nightly \
--platform linux/amd64,linux/arm64 . --platform linux/amd64,linux/arm64 .
--build-arg COMMIT=$(git rev-parse HEAD) \ --build-args COMMIT=$(git rev-parse HEAD) \
# #
# Build Discord Notification # Build Discord Notification
# #

View File

@ -3,6 +3,7 @@ import { AdminTaskAPI } from "./admin/admin-tasks";
import { AdminUsersApi } from "./admin/admin-users"; import { AdminUsersApi } from "./admin/admin-users";
import { AdminGroupsApi } from "./admin/admin-groups"; import { AdminGroupsApi } from "./admin/admin-groups";
import { AdminBackupsApi } from "./admin/admin-backups"; import { AdminBackupsApi } from "./admin/admin-backups";
import { AdminMaintenanceApi } from "./admin/admin-maintenance";
import { ApiRequestInstance } from "~/types/api"; import { ApiRequestInstance } from "~/types/api";
export class AdminAPI { export class AdminAPI {
@ -11,6 +12,7 @@ export class AdminAPI {
public users: AdminUsersApi; public users: AdminUsersApi;
public groups: AdminGroupsApi; public groups: AdminGroupsApi;
public backups: AdminBackupsApi; public backups: AdminBackupsApi;
public maintenance: AdminMaintenanceApi;
constructor(requests: ApiRequestInstance) { constructor(requests: ApiRequestInstance) {
this.about = new AdminAboutAPI(requests); this.about = new AdminAboutAPI(requests);
@ -18,6 +20,7 @@ export class AdminAPI {
this.users = new AdminUsersApi(requests); this.users = new AdminUsersApi(requests);
this.groups = new AdminGroupsApi(requests); this.groups = new AdminGroupsApi(requests);
this.backups = new AdminBackupsApi(requests); this.backups = new AdminBackupsApi(requests);
this.maintenance = new AdminMaintenanceApi(requests);
Object.freeze(this); Object.freeze(this);
} }

View File

@ -0,0 +1,30 @@
import { BaseAPI } from "../_base";
import { SuccessResponse } from "~/types/api-types/response";
import { MaintenanceSummary } from "~/types/api-types/admin";
const prefix = "/api";
const routes = {
base: `${prefix}/admin/maintenance`,
cleanImages: `${prefix}/admin/maintenance/clean/images`,
cleanRecipeFolders: `${prefix}/admin/maintenance/clean/recipe-folders`,
cleanLogFile: `${prefix}/admin/maintenance/clean/logs`,
};
export class AdminMaintenanceApi extends BaseAPI {
async getInfo() {
return this.requests.get<MaintenanceSummary>(routes.base);
}
async cleanImages() {
return await this.requests.post<SuccessResponse>(routes.cleanImages, {});
}
async cleanRecipeFolders() {
return await this.requests.post<SuccessResponse>(routes.cleanRecipeFolders, {});
}
async cleanLogFile() {
return await this.requests.post<SuccessResponse>(routes.cleanLogFile, {});
}
}

View File

@ -54,6 +54,11 @@ export default defineComponent({
to: "/admin/site-settings", to: "/admin/site-settings",
title: i18n.t("sidebar.site-settings"), title: i18n.t("sidebar.site-settings"),
}, },
{
icon: $globals.icons.cog,
to: "/admin/maintenance",
title: "Maintenance",
},
{ {
icon: $globals.icons.user, icon: $globals.icons.user,
to: "/admin/manage/users", to: "/admin/manage/users",

View File

@ -0,0 +1,165 @@
<template>
<v-container fluid class="narrow-container">
<BasePageTitle divider>
<template #title> Site Maintenance </template>
</BasePageTitle>
<BannerExperimental />
<section>
<BaseCardSectionTitle class="pb-0" :icon="$globals.icons.cog" title="Summary"> </BaseCardSectionTitle>
<div class="mb-6 ml-2">
<BaseButton color="info" @click="getSummary">
<template #icon> {{ $globals.icons.tools }} </template>
Get Summary
</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>
<div>{{ value.name }}</div>
</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">
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-title>
<div>{{ action.name }}</div>
<v-list-item-subtitle>
{{ 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>
</v-list-item>
<v-divider :key="`divider-${idx}`" class="mx-2"></v-divider>
</template>
</v-card>
</section>
</v-container>
</template>
<script lang="ts">
import { computed, ref, defineComponent, reactive } from "@nuxtjs/composition-api";
import { useAdminApi } from "~/composables/api";
import { MaintenanceSummary } from "~/types/api-types/admin";
export default defineComponent({
layout: "admin",
setup() {
const state = reactive({
fetchingInfo: false,
actionLoading: false,
});
const adminApi = useAdminApi();
const infoResults = ref<MaintenanceSummary>({
dataDirSize: "unknown",
logFileSize: "unknown",
cleanableDirs: 0,
cleanableImages: 0,
});
async function getSummary() {
state.fetchingInfo = true;
const { data } = await adminApi.maintenance.getInfo();
infoResults.value = data ?? {
dataDirSize: "unknown",
logFileSize: "unknown",
cleanableDirs: 0,
cleanableImages: 0,
};
state.fetchingInfo = false;
}
const info = computed(() => {
return [
{
name: "Data Directory Size",
value: infoResults.value.dataDirSize,
},
{
name: "Log File Size",
value: infoResults.value.logFileSize,
},
{
name: "Cleanable Directories",
value: infoResults.value.cleanableDirs,
},
{
name: "Cleanable Images",
value: infoResults.value.cleanableImages,
},
];
});
async function handleDeleteLogFile() {
state.actionLoading = true;
await adminApi.maintenance.cleanLogFile();
state.actionLoading = false;
}
async function handleCleanDirectories() {
state.actionLoading = true;
await adminApi.maintenance.cleanRecipeFolders();
state.actionLoading = false;
}
async function handleCleanImages() {
state.actionLoading = true;
await adminApi.maintenance.cleanImages();
state.actionLoading = false;
}
const actions = [
{
name: "Delete Log Files",
handler: handleDeleteLogFile,
subtitle: "Deletes all the log files",
},
{
name: "Clean Directories",
handler: handleCleanDirectories,
subtitle: "Removes all the recipe folders that are not valid UUIDs",
},
{
name: "Clean Images",
handler: handleCleanImages,
subtitle: "Removes all the images that don't end with .webp",
},
];
return {
state,
info,
getSummary,
actions,
};
},
head() {
return {
title: this.$t("settings.site-settings") as string,
};
},
});
</script>
<style scoped></style>

View File

@ -190,6 +190,12 @@ export interface ImportJob {
force?: boolean; force?: boolean;
rebase?: boolean; rebase?: boolean;
} }
export interface MaintenanceSummary {
dataDirSize: string;
logFileSize: string;
cleanableImages: number;
cleanableDirs: number;
}
export interface MigrationFile { export interface MigrationFile {
name: string; name: string;
date: string; date: string;

28
mealie/pkgs/img/static.py Normal file
View File

@ -0,0 +1,28 @@
NOT_WEBP = {
".jpg",
".jpeg",
".jpe",
".jif",
".jfif",
".jfi",
".png",
".gif",
".tiff",
".tif",
".psd",
".raw",
".arw",
".cr2",
".nrw",
".k25",
".bmp",
".dib",
".heif",
".heic",
".ind",
".jp2",
".svg",
".svgz",
".ai",
".eps",
}

View File

@ -1,3 +1,7 @@
import os
from pathlib import Path
def pretty_size(size: int) -> str: def pretty_size(size: int) -> str:
""" """
Pretty size takes in a integer value of a file size and returns the most applicable Pretty size takes in a integer value of a file size and returns the most applicable
@ -13,3 +17,17 @@ def pretty_size(size: int) -> str:
return f"{round(size / 1024 / 1024 / 1024, 2)} GB" return f"{round(size / 1024 / 1024 / 1024, 2)} GB"
else: else:
return f"{round(size / 1024 / 1024 / 1024 / 1024, 2)} TB" return f"{round(size / 1024 / 1024 / 1024 / 1024, 2)} TB"
def get_dir_size(path: Path | str) -> int:
"""
Get the size of a directory
"""
total_size = os.path.getsize(path)
for item in os.listdir(path):
itempath = os.path.join(path, item)
if os.path.isfile(itempath):
total_size += os.path.getsize(itempath)
elif os.path.isdir(itempath):
total_size += get_dir_size(itempath)
return total_size

View File

@ -5,6 +5,7 @@ from . import (
admin_backups, admin_backups,
admin_email, admin_email,
admin_log, admin_log,
admin_maintenance,
admin_management_groups, admin_management_groups,
admin_management_users, admin_management_users,
admin_server_tasks, admin_server_tasks,
@ -18,4 +19,5 @@ router.include_router(admin_management_users.router)
router.include_router(admin_management_groups.router) router.include_router(admin_management_groups.router)
router.include_router(admin_email.router, tags=["Admin: Email"]) router.include_router(admin_email.router, tags=["Admin: Email"])
router.include_router(admin_server_tasks.router, tags=["Admin: Server Tasks"]) router.include_router(admin_server_tasks.router, tags=["Admin: Server Tasks"])
router.include_router(admin_backups.router) router.include_router(admin_backups.router, tags=["Admin: Backups"])
router.include_router(admin_maintenance.router, tags=["Admin: Maintenance"])

View File

@ -0,0 +1,108 @@
import contextlib
import os
import shutil
import uuid
from pathlib import Path
from fastapi import APIRouter, HTTPException
from mealie.core.root_logger import LOGGER_FILE
from mealie.pkgs.stats import fs_stats
from mealie.routes._base import BaseAdminController, controller
from mealie.schema.admin import MaintenanceSummary
from mealie.schema.response import ErrorResponse, SuccessResponse
router = APIRouter(prefix="/maintenance")
def clean_images(root_dir: Path, dry_run: bool) -> int:
cleaned_images = 0
for recipe_dir in root_dir.iterdir():
image_dir = recipe_dir.joinpath("images")
if not image_dir.exists():
continue
for image in image_dir.iterdir():
if image.is_dir():
continue
if image.suffix != ".webp":
if not dry_run:
image.unlink()
cleaned_images += 1
return cleaned_images
def clean_recipe_folders(root_dir: Path, dry_run: bool) -> int:
cleaned_dirs = 0
for recipe_dir in root_dir.iterdir():
if recipe_dir.is_dir():
# Attemp to convert the folder name to a UUID
try:
uuid.UUID(recipe_dir.name)
continue
except ValueError:
if not dry_run:
shutil.rmtree(recipe_dir)
cleaned_dirs += 1
return cleaned_dirs
@controller(router)
class AdminMaintenanceController(BaseAdminController):
@router.get("", response_model=MaintenanceSummary)
def get_maintenance_summary(self):
"""
Get the maintenance summary
"""
log_file_size = 0
with contextlib.suppress(FileNotFoundError):
log_file_size = os.path.getsize(LOGGER_FILE)
return MaintenanceSummary(
data_dir_size=fs_stats.pretty_size(fs_stats.get_dir_size(self.deps.folders.DATA_DIR)),
log_file_size=fs_stats.pretty_size(log_file_size),
cleanable_images=clean_images(self.deps.folders.RECIPE_DATA_DIR, dry_run=True),
cleanable_dirs=clean_recipe_folders(self.deps.folders.RECIPE_DATA_DIR, dry_run=True),
)
@router.post("/clean/images", response_model=SuccessResponse)
def clean_images(self):
"""
Purges all the images from the filesystem that aren't .webp
"""
try:
cleaned_images = clean_images(self.deps.folders.RECIPE_DATA_DIR, dry_run=False)
return SuccessResponse.respond(f"{cleaned_images} Images cleaned")
except Exception as e:
raise HTTPException(status_code=500, detail=ErrorResponse.respond("Failed to clean images")) from e
@router.post("/clean/recipe-folders", response_model=SuccessResponse)
def clean_recipe_folders(self):
"""
Deletes all the recipe folders that don't have names that are valid UUIDs
"""
try:
cleaned_dirs = clean_recipe_folders(self.deps.folders.RECIPE_DATA_DIR, dry_run=False)
return SuccessResponse.respond(f"{cleaned_dirs} Recipe folders removed")
except Exception as e:
raise HTTPException(status_code=500, detail=ErrorResponse.respond("Failed to clean directories")) from e
@router.post("/clean/logs", response_model=SuccessResponse)
def clean_logs(self):
"""
Purges the logs
"""
try:
with contextlib.suppress(FileNotFoundError):
os.remove(LOGGER_FILE)
LOGGER_FILE.touch()
return SuccessResponse.respond("Logs cleaned")
except Exception as e:
raise HTTPException(status_code=500, detail=ErrorResponse.respond("Failed to clean logs")) from e

View File

@ -1,6 +1,7 @@
# GENERATED CODE - DO NOT MODIFY BY HAND # GENERATED CODE - DO NOT MODIFY BY HAND
from .about import * from .about import *
from .backup import * from .backup import *
from .maintenance import *
from .migration import * from .migration import *
from .restore import * from .restore import *
from .settings import * from .settings import *

View File

@ -0,0 +1,8 @@
from fastapi_camelcase import CamelModel
class MaintenanceSummary(CamelModel):
data_dir_size: str
log_file_size: str
cleanable_images: int
cleanable_dirs: int