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

feat: docker volume validation (#1125)

* feat: add api endpoints for volume check

* feat: add docker icon

* add size prop

* feat: add frontend UI for checking docker-volume

* update caddy to server validation file

* add more extensive documentation around setup req

* fix: wrong type on user id #1123

* spelling

* refactor: cleanup excessive function calls
This commit is contained in:
Hayden 2022-04-02 16:35:53 -08:00 committed by GitHub
parent ea141832c3
commit e9bb39c744
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 612 additions and 157 deletions

View file

@ -20,13 +20,19 @@
file_server
}
# Handles User Images
# Handles User Images
handle_path /api/media/users/* {
header @static Cache-Control max-age=31536000
root * /app/data/users/
file_server
}
# Handle Docker Volume Validation File
handle_path /api/media/docker/* {
root * /app/data/docker-validation/
file_server
}
handle @apidocs {
uri strip_suffix /
@ -37,4 +43,4 @@
uri strip_suffix /
reverse_proxy http://127.0.0.1:3001
}
}
}

View file

@ -1,5 +1,5 @@
import { BaseAPI } from "../_base";
import { AdminAboutInfo } from "~/types/api-types/admin";
import { AdminAboutInfo, DockerVolumeText, CheckAppConfig } from "~/types/api-types/admin";
const prefix = "/api";
@ -7,25 +7,10 @@ const routes = {
about: `${prefix}/admin/about`,
aboutStatistics: `${prefix}/admin/about/statistics`,
check: `${prefix}/admin/about/check`,
docker: `${prefix}/admin/about/docker/validate`,
validationFile: `${prefix}/media/docker/validate.txt`,
};
export interface AdminStatistics {
totalRecipes: number;
totalUsers: number;
totalGroups: number;
uncategorizedRecipes: number;
untaggedRecipes: number;
}
export interface CheckAppConfig {
emailReady: boolean;
baseUrlSet: boolean;
isSiteSecure: boolean;
isUpToDate: boolean;
ldapReady: boolean;
}
export class AdminAboutAPI extends BaseAPI {
async about() {
return await this.requests.get<AdminAboutInfo>(routes.about);
@ -38,4 +23,12 @@ export class AdminAboutAPI extends BaseAPI {
async checkApp() {
return await this.requests.get<CheckAppConfig>(routes.check);
}
async checkDocker() {
return await this.requests.get<DockerVolumeText>(routes.docker);
}
async getDockerValidateFileContents() {
return await this.requests.get<string>(routes.validationFile);
}
}

View file

@ -2,8 +2,8 @@
<div class="text-center">
<v-menu top offset-y left open-on-hover>
<template #activator="{ on, attrs }">
<v-btn icon v-bind="attrs" v-on="on" @click.stop>
<v-icon> {{ $globals.icons.help }} </v-icon>
<v-btn :small="small" icon v-bind="attrs" v-on="on" @click.stop>
<v-icon :small="small"> {{ $globals.icons.help }} </v-icon>
</v-btn>
</template>
<v-card max-width="300px">
@ -19,8 +19,11 @@
import { defineComponent } from "@nuxtjs/composition-api";
export default defineComponent({
setup() {
return {};
props: {
small: {
type: Boolean,
default: false,
},
},
});
</script>

View file

@ -8,26 +8,67 @@
</BasePageTitle>
<section>
<BaseCardSectionTitle class="pb-0" :icon="$globals.icons.cog" title="General Configuration">
</BaseCardSectionTitle>
<BaseCardSectionTitle class="pb-0" :icon="$globals.icons.cog" title="Configuration"> </BaseCardSectionTitle>
<v-card class="mb-4">
<template v-for="(check, idx) in simpleChecks">
<v-list-item :key="`list-item-${idx}`">
<v-list-item-icon>
<v-icon :color="check.color">
{{ check.icon }}
</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>
{{ check.text }}
</v-list-item-title>
<v-list-item-subtitle class="wrap-word">
{{ check.status ? check.successText : check.errorText }}
</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
<v-divider :key="`divider-${idx}`"></v-divider>
</template>
</v-card>
</section>
<section>
<BaseCardSectionTitle class="pt-2" :icon="$globals.icons.docker" title="Docker Volume" />
<v-alert
v-for="(check, idx) in simpleChecks"
:key="idx"
border="left"
colored-border
:type="getColor(check.status, check.warning)"
:type="docker.state === DockerVolumeState.Error ? 'error' : 'info'"
:icon="$globals.icons.docker"
elevation="2"
:loading="docker.loading"
>
<div class="font-weight-medium">{{ check.text }}</div>
<div class="d-flex align-center font-weight-medium">
Docker Volume
<HelpIcon small class="my-n3">
Mealie requires that the frontend container and the backend share the same docker volume or storage. This
ensures that the frontend container can properly access the images and assets stored on disk.
</HelpIcon>
</div>
<div>
{{ check.status ? check.successText : check.errorText }}
<template v-if="docker.state === DockerVolumeState.Error"> Volumes are misconfigured. </template>
<template v-else-if="docker.state === DockerVolumeState.Success">
Volumes are configured correctly.
</template>
<template v-else-if="docker.state === DockerVolumeState.Unknown">
Status Unknown. Try running a validation.
</template>
</div>
<div class="mt-4">
<BaseButton color="info" :loading="docker.loading" @click="dockerValidate">
<template #icon> {{ $globals.icons.checkboxMarkedCircle }} </template>
Validate
</BaseButton>
</div>
</v-alert>
</section>
<section>
<BaseCardSectionTitle class="pt-2" :icon="$globals.icons.email" title="Email Configuration" />
<v-alert border="left" colored-border :type="getColor(appConfig.emailReady)" elevation="2">
<BaseCardSectionTitle class="pt-2" :icon="$globals.icons.email" title="Email" />
<v-alert border="left" colored-border :type="appConfig.emailReady ? 'success' : 'error'" elevation="2">
<div class="font-weight-medium">Email Configuration Status</div>
<div>
{{ appConfig.emailReady ? "Ready" : "Not Ready - Check Environmental Variables" }}
@ -51,13 +92,6 @@
<span class="pl-4">
{{ success ? "Succeeded" : "Failed" }}
</span>
<!-- <template v-if="errors">
<h4>Errors:</h4>
<span class="pl-4">
{{ errors }}
</span>
</template> -->
</v-card-text>
</template>
</div>
@ -95,22 +129,62 @@ import {
useAsync,
useContext,
} from "@nuxtjs/composition-api";
import { CheckAppConfig } from "~/api/admin/admin-about";
import { useAdminApi, useUserApi } from "~/composables/api";
import { validators } from "~/composables/use-validators";
import { useAsyncKey } from "~/composables/use-utils";
import { CheckAppConfig } from "~/types/api-types/admin";
enum DockerVolumeState {
Unknown = "unknown",
Success = "success",
Error = "error",
}
interface SimpleCheck {
status: boolean;
text: string;
status: boolean | undefined;
successText: string;
errorText: string;
warning: boolean;
color: string;
icon: string;
}
interface CheckApp extends CheckAppConfig {
isSiteSecure?: boolean;
}
export default defineComponent({
layout: "admin",
setup() {
// ==========================================================
// Docker Volume Validation
const docker = reactive({
loading: false,
state: DockerVolumeState.Unknown,
});
async function dockerValidate() {
docker.loading = true;
// Do API Check
const { data } = await adminApi.about.checkDocker();
if (data == null) {
docker.state = DockerVolumeState.Error;
return;
}
// Get File Contents
const { data: fileContents } = await adminApi.about.getDockerValidateFileContents();
if (data.text === fileContents) {
docker.state = DockerVolumeState.Success;
} else {
docker.state = DockerVolumeState.Error;
}
docker.loading = false;
}
const state = reactive({
loading: false,
address: "",
@ -119,17 +193,21 @@ export default defineComponent({
tested: false,
});
const appConfig = ref<CheckAppConfig>({
emailReady: false,
baseUrlSet: false,
isSiteSecure: false,
const appConfig = ref<CheckApp>({
emailReady: true,
baseUrlSet: true,
isSiteSecure: true,
isUpToDate: false,
ldapReady: false,
});
const api = useUserApi();
function isLocalHostOrHttps() {
return window.location.hostname === "localhost" || window.location.protocol === "https:";
}
const api = useUserApi();
const adminApi = useAdminApi();
onMounted(async () => {
const { data } = await adminApi.about.checkApp();
@ -140,43 +218,53 @@ export default defineComponent({
appConfig.value.isSiteSecure = isLocalHostOrHttps();
});
function isLocalHostOrHttps() {
return window.location.hostname === "localhost" || window.location.protocol === "https:";
}
const simpleChecks = computed<SimpleCheck[]>(() => {
return [
const goodIcon = $globals.icons.checkboxMarkedCircle;
const badIcon = $globals.icons.alert;
const warningIcon = $globals.icons.alertCircle;
const goodColor = "success";
const badColor = "error";
const warningColor = "warning";
const data: SimpleCheck[] = [
{
status: appConfig.value.isUpToDate,
text: "Application Version",
status: appConfig.value.isUpToDate,
errorText: `Your current version (${rawAppInfo.value.version}) does not match the latest release. Considering updating to the latest version (${rawAppInfo.value.versionLatest}).`,
successText: "Mealie is up to date",
warning: true,
color: appConfig.value.isUpToDate ? goodColor : warningColor,
icon: appConfig.value.isUpToDate ? goodIcon : warningIcon,
},
{
status: appConfig.value.isSiteSecure,
text: "Secure Site",
status: appConfig.value.isSiteSecure,
errorText: "Serve via localhost or secure with https. Clipboard and additional browser APIs may not work.",
successText: "Site is accessed by localhost or https",
warning: false,
color: appConfig.value.isSiteSecure ? goodColor : badColor,
icon: appConfig.value.isSiteSecure ? goodIcon : badIcon,
},
{
status: appConfig.value.baseUrlSet,
text: "Server Side Base URL",
status: appConfig.value.baseUrlSet,
errorText:
"`BASE_URL` is still the default value on API Server. This will cause issues with notifications links generated on the server for emails, etc.",
successText: "Server Side URL does not match the default",
warning: false,
color: appConfig.value.baseUrlSet ? goodColor : badColor,
icon: appConfig.value.baseUrlSet ? goodIcon : badIcon,
},
{
status: appConfig.value.ldapReady,
text: "LDAP Ready",
status: appConfig.value.ldapReady,
errorText:
"Not all LDAP Values are configured. This can be ignored if you are not using LDAP Authentication.",
successText: "Required LDAP variables are all set.",
warning: true,
color: appConfig.value.ldapReady ? goodColor : warningColor,
icon: appConfig.value.ldapReady ? goodIcon : warningIcon,
},
];
return data;
});
async function testEmail() {
@ -209,11 +297,6 @@ export default defineComponent({
return false;
});
function getColor(booly: unknown, warning = false) {
const falsey = warning ? "warning" : "error";
return booly ? "success" : falsey;
}
// ============================================================
// General About Info
@ -292,8 +375,10 @@ export default defineComponent({
const appInfo = getAppInfo();
return {
DockerVolumeState,
docker,
dockerValidate,
simpleChecks,
getColor,
appConfig,
validEmail,
validators,
@ -310,4 +395,9 @@ export default defineComponent({
});
</script>
<style scoped></style>
<style scoped>
.wrap-word {
white-space: normal;
word-wrap: break-word;
}
</style>

View file

@ -170,6 +170,9 @@ export interface CustomPageOut {
categories?: RecipeCategoryResponse[];
id: number;
}
export interface DockerVolumeText {
text: string;
}
export interface GroupImport {
name: string;
status: boolean;

View file

@ -1,76 +1,74 @@
// This Code is auto generated by gen_global_components.py
import BaseCardSectionTitle from "@/components/global/BaseCardSectionTitle.vue";
import MarkdownEditor from "@/components/global/MarkdownEditor.vue";
import AppLoader from "@/components/global/AppLoader.vue";
import BaseOverflowButton from "@/components/global/BaseOverflowButton.vue";
import ReportTable from "@/components/global/ReportTable.vue";
import AppToolbar from "@/components/global/AppToolbar.vue";
import BaseButtonGroup from "@/components/global/BaseButtonGroup.vue";
import BaseButton from "@/components/global/BaseButton.vue";
import BannerExperimental from "@/components/global/BannerExperimental.vue";
import BaseDialog from "@/components/global/BaseDialog.vue";
import RecipeJsonEditor from "@/components/global/RecipeJsonEditor.vue";
import StatsCards from "@/components/global/StatsCards.vue";
import HelpIcon from "@/components/global/HelpIcon.vue";
import InputLabelType from "@/components/global/InputLabelType.vue";
import BaseStatCard from "@/components/global/BaseStatCard.vue";
import DevDumpJson from "@/components/global/DevDumpJson.vue";
import LanguageDialog from "@/components/global/LanguageDialog.vue";
import InputQuantity from "@/components/global/InputQuantity.vue";
import ToggleState from "@/components/global/ToggleState.vue";
import AppButtonCopy from "@/components/global/AppButtonCopy.vue";
import CrudTable from "@/components/global/CrudTable.vue";
import InputColor from "@/components/global/InputColor.vue";
import BaseDivider from "@/components/global/BaseDivider.vue";
import AutoForm from "@/components/global/AutoForm.vue";
import AppButtonUpload from "@/components/global/AppButtonUpload.vue";
import AdvancedOnly from "@/components/global/AdvancedOnly.vue";
import BasePageTitle from "@/components/global/BasePageTitle.vue";
import ButtonLink from "@/components/global/ButtonLink.vue";
import TheSnackbar from "@/components/layout/TheSnackbar.vue";
import AppHeader from "@/components/layout/AppHeader.vue";
import AppSidebar from "@/components/layout/AppSidebar.vue";
import AppFooter from "@/components/layout/AppFooter.vue";
import BaseCardSectionTitle from "@/components/global/BaseCardSectionTitle.vue";
import MarkdownEditor from "@/components/global/MarkdownEditor.vue";
import AppLoader from "@/components/global/AppLoader.vue";
import BaseOverflowButton from "@/components/global/BaseOverflowButton.vue";
import ReportTable from "@/components/global/ReportTable.vue";
import AppToolbar from "@/components/global/AppToolbar.vue";
import BaseButtonGroup from "@/components/global/BaseButtonGroup.vue";
import BaseButton from "@/components/global/BaseButton.vue";
import BannerExperimental from "@/components/global/BannerExperimental.vue";
import BaseDialog from "@/components/global/BaseDialog.vue";
import RecipeJsonEditor from "@/components/global/RecipeJsonEditor.vue";
import StatsCards from "@/components/global/StatsCards.vue";
import HelpIcon from "@/components/global/HelpIcon.vue";
import InputLabelType from "@/components/global/InputLabelType.vue";
import BaseStatCard from "@/components/global/BaseStatCard.vue";
import DevDumpJson from "@/components/global/DevDumpJson.vue";
import LanguageDialog from "@/components/global/LanguageDialog.vue";
import InputQuantity from "@/components/global/InputQuantity.vue";
import ToggleState from "@/components/global/ToggleState.vue";
import AppButtonCopy from "@/components/global/AppButtonCopy.vue";
import CrudTable from "@/components/global/CrudTable.vue";
import InputColor from "@/components/global/InputColor.vue";
import BaseDivider from "@/components/global/BaseDivider.vue";
import AutoForm from "@/components/global/AutoForm.vue";
import AppButtonUpload from "@/components/global/AppButtonUpload.vue";
import AdvancedOnly from "@/components/global/AdvancedOnly.vue";
import BasePageTitle from "@/components/global/BasePageTitle.vue";
import ButtonLink from "@/components/global/ButtonLink.vue";
import TheSnackbar from "@/components/layout/TheSnackbar.vue";
import AppHeader from "@/components/layout/AppHeader.vue";
import AppSidebar from "@/components/layout/AppSidebar.vue";
import AppFooter from "@/components/layout/AppFooter.vue";
declare module "vue" {
export interface GlobalComponents {
// Global Components
BaseCardSectionTitle: typeof BaseCardSectionTitle;
MarkdownEditor: typeof MarkdownEditor;
AppLoader: typeof AppLoader;
BaseOverflowButton: typeof BaseOverflowButton;
ReportTable: typeof ReportTable;
AppToolbar: typeof AppToolbar;
BaseButtonGroup: typeof BaseButtonGroup;
BaseButton: typeof BaseButton;
BannerExperimental: typeof BannerExperimental;
BaseDialog: typeof BaseDialog;
RecipeJsonEditor: typeof RecipeJsonEditor;
StatsCards: typeof StatsCards;
HelpIcon: typeof HelpIcon;
InputLabelType: typeof InputLabelType;
BaseStatCard: typeof BaseStatCard;
DevDumpJson: typeof DevDumpJson;
LanguageDialog: typeof LanguageDialog;
InputQuantity: typeof InputQuantity;
ToggleState: typeof ToggleState;
AppButtonCopy: typeof AppButtonCopy;
CrudTable: typeof CrudTable;
InputColor: typeof InputColor;
BaseDivider: typeof BaseDivider;
AutoForm: typeof AutoForm;
AppButtonUpload: typeof AppButtonUpload;
AdvancedOnly: typeof AdvancedOnly;
BasePageTitle: typeof BasePageTitle;
ButtonLink: typeof ButtonLink;
// Layout Components
TheSnackbar: typeof TheSnackbar;
AppHeader: typeof AppHeader;
AppSidebar: typeof AppSidebar;
AppFooter: typeof AppFooter;
BaseCardSectionTitle: typeof BaseCardSectionTitle;
MarkdownEditor: typeof MarkdownEditor;
AppLoader: typeof AppLoader;
BaseOverflowButton: typeof BaseOverflowButton;
ReportTable: typeof ReportTable;
AppToolbar: typeof AppToolbar;
BaseButtonGroup: typeof BaseButtonGroup;
BaseButton: typeof BaseButton;
BannerExperimental: typeof BannerExperimental;
BaseDialog: typeof BaseDialog;
RecipeJsonEditor: typeof RecipeJsonEditor;
StatsCards: typeof StatsCards;
HelpIcon: typeof HelpIcon;
InputLabelType: typeof InputLabelType;
BaseStatCard: typeof BaseStatCard;
DevDumpJson: typeof DevDumpJson;
LanguageDialog: typeof LanguageDialog;
InputQuantity: typeof InputQuantity;
ToggleState: typeof ToggleState;
AppButtonCopy: typeof AppButtonCopy;
CrudTable: typeof CrudTable;
InputColor: typeof InputColor;
BaseDivider: typeof BaseDivider;
AutoForm: typeof AutoForm;
AppButtonUpload: typeof AppButtonUpload;
AdvancedOnly: typeof AdvancedOnly;
BasePageTitle: typeof BasePageTitle;
ButtonLink: typeof ButtonLink;
// Layout Components
TheSnackbar: typeof TheSnackbar;
AppHeader: typeof AppHeader;
AppSidebar: typeof AppSidebar;
AppFooter: typeof AppFooter;
}
}

View file

@ -3,6 +3,7 @@ export interface Icon {
primary: string;
// General
docker: string;
chart: string;
wrench: string;
help: string;

View file

@ -108,6 +108,7 @@ import {
mdiWrench,
mdiChartLine,
mdiHelpCircleOutline,
mdiDocker,
} from "@mdi/js";
export const icons = {
@ -116,6 +117,7 @@ export const icons = {
wrench: mdiWrench,
chart: mdiChartLine,
docker: mdiDocker,
// General
bowlMixOutline: mdiBowlMixOutline,