feat: move to iframe for changelog (#1388)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Changelog modal now displays changelog documentation within an
embedded iframe if a URL is available, allowing navigation within the
iframe and providing a "Back to Changelog" button to return to the
original view.
- "View on Docs" button dynamically updates to reflect the current page
within the iframe.
- Added support for displaying a formatted changelog string and testing
modal behavior with or without this content.
- Introduced a new component to fetch, parse, and render changelogs from
URLs with enhanced markdown handling.
- Update OS store extended to manage changelog display and release
stability, consolidating changelog state and actions.

- **Bug Fixes**
  - Close button in modal dialogs is now visible on all screen sizes.

- **Chores**
- Updated the default server state, which may affect registration device
counts and types.
- Added new localization string for changelog titles with version
placeholders.
- Removed deprecated changelog store and related tests, simplifying
state management.
- Refined backup and restoration scripts for unraid-components directory
to use move operations with improved logging.
- Improved modal visibility state handling by consolidating changelog
modal controls into the main update OS store.
- Added URL origin and pattern checks for changelog iframe navigation
security.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
Eli Bosley
2025-05-19 12:34:44 -07:00
committed by GitHub
parent fc68ea03d1
commit fcd6fbcdd4
15 changed files with 380 additions and 434 deletions

View File

@@ -134,8 +134,8 @@ done
# Handle the unraid-components directory # Handle the unraid-components directory
DIR=/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components DIR=/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components
if [ -d "$DIR" ] && [ ! -d "$DIR-" ]; then if [ -d "$DIR" ] && [ ! -d "$DIR-" ]; then
cp -rp "$DIR" "$DIR-" mv "$DIR" "$DIR-"
echo "Backed up directory: $DIR" echo "Moved directory: $DIR to $DIR-"
fi fi
echo "Backup complete." echo "Backup complete."
@@ -204,12 +204,13 @@ exit 0
# Handle the unraid-components directory # Handle the unraid-components directory
DIR=/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components DIR=/usr/local/emhttp/plugins/dynamix.my.servers/unraid-components
# certain instances where the directory is not present and others where it is, ensure we delete it before we restore it # Remove the archive's contents before restoring
if [ -d "$DIR" ]; then if [ -d "$DIR" ]; then
rm -rf "$DIR" rm -rf "$DIR"
fi fi
if [ -d "$DIR-" ]; then if [ -d "$DIR-" ]; then
mv -f "$DIR-" "$DIR" mv "$DIR-" "$DIR"
echo "Restored directory: $DIR- to $DIR"
fi fi
]]> ]]>
</INLINE> </INLINE>

View File

@@ -402,6 +402,7 @@ class WebComponentTranslations
'Your license key is not eligible for Unraid OS {0}' => sprintf(_('Your license key is not eligible for Unraid OS %s'), '{0}'), 'Your license key is not eligible for Unraid OS {0}' => sprintf(_('Your license key is not eligible for Unraid OS %s'), '{0}'),
'Your Trial has expired' => _('Your Trial has expired'), 'Your Trial has expired' => _('Your Trial has expired'),
'Your Trial key has been extended!' => _('Your Trial key has been extended!'), 'Your Trial key has been extended!' => _('Your Trial key has been extended!'),
'Unraid OS {0} Changelog' => sprintf(_('Unraid OS %s Changelog'), '{0}'),
]; ];
} }

View File

@@ -58,7 +58,7 @@ describe('UpdateOs Store', () => {
describe('State and Getters', () => { describe('State and Getters', () => {
it('should initialize with correct default values', () => { it('should initialize with correct default values', () => {
expect(store.checkForUpdatesLoading).toBe(false); expect(store.checkForUpdatesLoading).toBe(false);
expect(store.modalOpen).toBe(false); expect(store.updateOsModalVisible).toBe(false);
}); });
it('should have computed properties with the right types', () => { it('should have computed properties with the right types', () => {
@@ -86,15 +86,15 @@ describe('UpdateOs Store', () => {
await store.localCheckForUpdate(); await store.localCheckForUpdate();
expect(WebguiCheckForUpdate).toHaveBeenCalled(); expect(WebguiCheckForUpdate).toHaveBeenCalled();
expect(store.modalOpen).toBe(true); expect(store.updateOsModalVisible).toBe(true);
}); });
it('should set modal open state', () => { it('should set modal open state', () => {
store.setModalOpen(true); store.setModalOpen(true);
expect(store.modalOpen).toBe(true); expect(store.updateOsModalVisible).toBe(true);
store.setModalOpen(false); store.setModalOpen(false);
expect(store.modalOpen).toBe(false); expect(store.updateOsModalVisible).toBe(false);
}); });
it('should handle errors when checking for updates', async () => { it('should handle errors when checking for updates', async () => {

View File

@@ -1,197 +0,0 @@
/**
* UpdateOsChangelog store test coverage
*/
import { nextTick } from 'vue';
import { createPinia, setActivePinia } from 'pinia';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { ServerUpdateOsResponse } from '~/types/server';
import { useUpdateOsChangelogStore } from '~/store/updateOsChangelog';
vi.mock('~/helpers/markdown', () => ({
Markdown: {
create: () => ({
setOptions: vi.fn(),
parse: vi.fn().mockResolvedValue('<h1>Test Title</h1><p>Test content</p>'),
}),
},
}));
vi.mock('~/helpers/urls', () => ({
DOCS_RELEASE_NOTES: {
toString: () => 'https://docs.unraid.net/unraid-os/release-notes/',
},
}));
vi.mock('marked-base-url', () => ({
baseUrl: vi.fn().mockReturnValue(vi.fn()),
}));
vi.mock('semver/functions/prerelease', () => ({
default: vi.fn((version) => (version && version.includes('-') ? ['beta', '1'] : null)),
}));
const mockRequestText = vi.fn().mockResolvedValue('# Test Changelog\n\nTest content');
vi.mock('~/composables/services/request', () => ({
request: {
url: () => ({
get: () => ({
text: mockRequestText,
}),
}),
},
}));
const mockSend = vi.fn();
vi.mock('~/store/callbackActions', () => ({
useCallbackActionsStore: () => ({
send: mockSend,
}),
}));
const mockStableRelease: Partial<ServerUpdateOsResponse> = {
version: '6.12.5',
name: 'Unraid 6.12.5',
date: '2023-10-15',
isEligible: true,
isNewer: true,
changelog: 'https://example.com/changelog.md',
changelogPretty: 'https://example.com/changelog',
sha256: 'test-sha256',
};
const mockBetaRelease: Partial<ServerUpdateOsResponse> = {
...mockStableRelease,
version: '6.12.5-beta1',
};
describe('UpdateOsChangelog Store', () => {
let store: ReturnType<typeof useUpdateOsChangelogStore>;
beforeEach(() => {
setActivePinia(createPinia());
store = useUpdateOsChangelogStore();
vi.clearAllMocks();
// Suppress console output
vi.spyOn(console, 'debug').mockImplementation(() => {});
vi.spyOn(console, 'error').mockImplementation(() => {});
});
describe('Store API', () => {
it('should initialize with default values', () => {
expect(store.releaseForUpdate).toBeNull();
expect(store.parseChangelogFailed).toBe('');
});
it('should set and get releaseForUpdate', () => {
store.setReleaseForUpdate(mockStableRelease as ServerUpdateOsResponse);
expect(store.releaseForUpdate).toEqual(mockStableRelease);
store.setReleaseForUpdate(null);
expect(store.releaseForUpdate).toBeNull();
});
it('should determine if release is stable', () => {
expect(store.isReleaseForUpdateStable).toBe(false);
store.setReleaseForUpdate(mockStableRelease as ServerUpdateOsResponse);
expect(store.isReleaseForUpdateStable).toBe(true);
store.setReleaseForUpdate(mockBetaRelease as ServerUpdateOsResponse);
expect(store.isReleaseForUpdateStable).toBe(false);
});
it('should have a method to fetch and confirm install', () => {
store.fetchAndConfirmInstall('test-sha256');
expect(mockSend).toHaveBeenCalledWith(
expect.any(String),
[
{
sha256: 'test-sha256',
type: 'updateOs',
},
],
undefined,
'forUpc'
);
});
it('should have computed properties for changelog display', async () => {
store.setReleaseForUpdate(mockStableRelease as ServerUpdateOsResponse);
expect(typeof store.mutatedParsedChangelog).toBe('string');
expect(typeof store.parsedChangelogTitle).toBe('string');
});
it('should clear changelog data when release is set to null', () => {
store.setReleaseForUpdate(mockStableRelease as ServerUpdateOsResponse);
store.setReleaseForUpdate(null);
expect(store.releaseForUpdate).toBeNull();
expect(store.parseChangelogFailed).toBe('');
});
it('should handle state transitions when changing releases', () => {
store.setReleaseForUpdate(mockStableRelease as ServerUpdateOsResponse);
const differentRelease = {
...mockStableRelease,
version: '6.12.6',
};
store.setReleaseForUpdate(differentRelease as ServerUpdateOsResponse);
expect(store.releaseForUpdate).toEqual(differentRelease);
});
it('should have proper error handling for failed requests', async () => {
mockRequestText.mockRejectedValueOnce(new Error('Network error'));
store.setReleaseForUpdate(mockStableRelease as ServerUpdateOsResponse);
await nextTick();
expect(store.parseChangelogFailed).toBeTruthy();
expect(store.parseChangelogFailed).toContain('error');
});
it('should fetch and parse changelog when releaseForUpdate changes', async () => {
const internalStore = useUpdateOsChangelogStore();
vi.clearAllMocks();
internalStore.setReleaseForUpdate(mockStableRelease as ServerUpdateOsResponse);
await nextTick();
expect(mockRequestText).toHaveBeenCalled();
mockRequestText.mockClear();
const differentRelease = {
...mockStableRelease,
version: '6.12.6',
changelog: 'https://example.com/different-changelog.md',
};
internalStore.setReleaseForUpdate(differentRelease as ServerUpdateOsResponse);
await nextTick();
expect(mockRequestText).toHaveBeenCalled();
mockRequestText.mockClear();
internalStore.setReleaseForUpdate(null);
await nextTick();
expect(mockRequestText).not.toHaveBeenCalled();
});
});
});

View File

@@ -47,7 +47,7 @@ import type {
// EBLACKLISTED2 // EBLACKLISTED2
// ENOCONN // ENOCONN
const state: ServerState = 'ENOKEYFILE' as ServerState; const state: ServerState = 'BASIC' as ServerState;
const currentFlashGuid = '1111-1111-YIJD-ZACK1234TEST'; // this is the flash drive that's been booted from const currentFlashGuid = '1111-1111-YIJD-ZACK1234TEST'; // this is the flash drive that's been booted from
const regGuid = '1111-1111-YIJD-ZACK1234TEST'; // this guid is registered in key server const regGuid = '1111-1111-YIJD-ZACK1234TEST'; // this guid is registered in key server
const keyfileBase64 = ''; const keyfileBase64 = '';

View File

@@ -121,7 +121,7 @@ const computedVerticalCenter = computed<string>(() => {
]" ]"
class="text-16px text-foreground bg-background text-left relative z-10 mx-auto flex flex-col justify-around border-2 border-solid transform overflow-hidden rounded-lg transition-all" class="text-16px text-foreground bg-background text-left relative z-10 mx-auto flex flex-col justify-around border-2 border-solid transform overflow-hidden rounded-lg transition-all"
> >
<div v-if="showCloseX" class="absolute z-20 right-0 top-0 pt-4px pr-4px hidden sm:block"> <div v-if="showCloseX" class="absolute z-20 right-0 top-0 pt-4px pr-4px sm:block">
<button <button
class="rounded-md text-foreground bg-transparent p-2 hover:text-white focus:text-white hover:bg-unraid-red focus:bg-unraid-red focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2" class="rounded-md text-foreground bg-transparent p-2 hover:text-white focus:text-white hover:bg-unraid-red focus:bg-unraid-red focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
type="button" type="button"

View File

@@ -5,14 +5,12 @@ import { storeToRefs } from 'pinia';
import { useCallbackActionsStore } from '~/store/callbackActions'; import { useCallbackActionsStore } from '~/store/callbackActions';
import { useTrialStore } from '~/store/trial'; import { useTrialStore } from '~/store/trial';
import { useUpdateOsStore } from '~/store/updateOs'; import { useUpdateOsStore } from '~/store/updateOs';
import { useUpdateOsChangelogStore } from '~/store/updateOsChangelog';
const { t } = useI18n(); const { t } = useI18n();
const { callbackStatus } = storeToRefs(useCallbackActionsStore()); const { callbackStatus } = storeToRefs(useCallbackActionsStore());
const { trialModalVisible } = storeToRefs(useTrialStore()); const { trialModalVisible } = storeToRefs(useTrialStore());
const { modalOpen: updateOsModalVisible } = storeToRefs(useUpdateOsStore()); const { updateOsModalVisible, changelogModalVisible } = storeToRefs(useUpdateOsStore());
const { releaseForUpdate: updateOsChangelogModalVisible } = storeToRefs(useUpdateOsChangelogStore());
</script> </script>
<template> <template>
@@ -20,7 +18,7 @@ const { releaseForUpdate: updateOsChangelogModalVisible } = storeToRefs(useUpdat
<UpcCallbackFeedback :t="t" :open="callbackStatus !== 'ready'" /> <UpcCallbackFeedback :t="t" :open="callbackStatus !== 'ready'" />
<UpcTrial :t="t" :open="trialModalVisible" /> <UpcTrial :t="t" :open="trialModalVisible" />
<UpdateOsCheckUpdateResponseModal :t="t" :open="updateOsModalVisible" /> <UpdateOsCheckUpdateResponseModal :t="t" :open="updateOsModalVisible" />
<UpdateOsChangelogModal :t="t" :open="!!updateOsChangelogModalVisible" /> <UpdateOsChangelogModal :t="t" :open="changelogModalVisible" />
<ActivationModal :t="t" /> <ActivationModal :t="t" />
</div> </div>
</template> </template>

View File

@@ -1,23 +1,22 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { import {
ArrowSmallRightIcon, ArrowLeftIcon,
ArrowRightIcon,
ArrowTopRightOnSquareIcon, ArrowTopRightOnSquareIcon,
EyeIcon,
KeyIcon, KeyIcon,
ServerStackIcon, ServerStackIcon,
XMarkIcon,
} from '@heroicons/vue/24/solid'; } from '@heroicons/vue/24/solid';
import { BrandButton, BrandLoading } from '@unraid/ui'; import { BrandButton, BrandLoading } from '@unraid/ui';
import type { ComposerTranslation } from 'vue-i18n'; import type { ComposerTranslation } from 'vue-i18n';
import RawChangelogRenderer from '~/components/UpdateOs/RawChangelogRenderer.vue';
import { usePurchaseStore } from '~/store/purchase'; import { usePurchaseStore } from '~/store/purchase';
import { useUpdateOsStore } from '~/store/updateOs'; import { useUpdateOsStore } from '~/store/updateOs';
// import { useUpdateOsActionsStore } from '~/store/updateOsActions'; import { allowedDocsOriginRegex, allowedDocsUrlRegex } from '~/helpers/urls';
import { useUpdateOsChangelogStore } from '~/store/updateOsChangelog';
export interface Props { export interface Props {
open?: boolean; open?: boolean;
@@ -30,86 +29,143 @@ const props = withDefaults(defineProps<Props>(), {
const purchaseStore = usePurchaseStore(); const purchaseStore = usePurchaseStore();
const updateOsStore = useUpdateOsStore(); const updateOsStore = useUpdateOsStore();
// const updateOsActionsStore = useUpdateOsActionsStore(); const { availableWithRenewal, releaseForUpdate, changelogModalVisible } = storeToRefs(updateOsStore);
const updateOsChangelogStore = useUpdateOsChangelogStore(); const { setReleaseForUpdate, fetchAndConfirmInstall } = updateOsStore;
const { availableWithRenewal } = storeToRefs(updateOsStore);
const { releaseForUpdate, mutatedParsedChangelog, parseChangelogFailed, parsedChangelogTitle } =
storeToRefs(updateOsChangelogStore);
const showExtendKeyButton = computed(() => { const showExtendKeyButton = computed(() => {
return availableWithRenewal.value; return availableWithRenewal.value;
}); });
// iframe navigation handling
const iframeRef = ref<HTMLIFrameElement | null>(null);
const hasNavigated = ref(false);
const currentIframeUrl = ref<string | null>(null);
const docsChangelogUrl = computed(() => {
return releaseForUpdate.value?.changelogPretty ?? null;
});
const showRawChangelog = computed<boolean>(() => {
return !docsChangelogUrl.value && !!releaseForUpdate.value?.changelog;
});
const handleIframeNavigationMessage = (event: MessageEvent) => {
if (
event.data &&
event.data.type === 'unraid-docs-navigation' &&
iframeRef.value &&
event.source === iframeRef.value.contentWindow &&
allowedDocsOriginRegex.test(event.origin)
) {
if (
typeof event.data.url === 'string' &&
allowedDocsUrlRegex.test(event.data.url)
) {
if (event.data.url !== docsChangelogUrl.value) {
hasNavigated.value = true;
} else {
hasNavigated.value = false;
}
currentIframeUrl.value = event.data.url;
}
}
};
onMounted(() => {
window.addEventListener('message', handleIframeNavigationMessage);
// Set initial value
currentIframeUrl.value = docsChangelogUrl.value;
});
onBeforeUnmount(() => {
window.removeEventListener('message', handleIframeNavigationMessage);
});
const revertToInitialChangelog = () => {
if (iframeRef.value && docsChangelogUrl.value) {
iframeRef.value.src = docsChangelogUrl.value;
hasNavigated.value = false;
currentIframeUrl.value = docsChangelogUrl.value;
}
};
watch(docsChangelogUrl, (newUrl) => {
currentIframeUrl.value = newUrl;
hasNavigated.value = false;
});
</script> </script>
<template> <template>
<Modal <Modal
v-if="releaseForUpdate?.version"
:center-content="false" :center-content="false"
:error="!!parseChangelogFailed"
max-width="max-w-800px" max-width="max-w-800px"
:open="!!releaseForUpdate" :open="changelogModalVisible"
:show-close-x="true" :show-close-x="true"
:t="t" :t="t"
:tall-content="true" :tall-content="true"
:title="parsedChangelogTitle ?? undefined" :title="t('Unraid OS {0} Changelog', [releaseForUpdate.version])"
@close="updateOsChangelogStore.setReleaseForUpdate(null)" :disable-overlay-close="false"
@close="setReleaseForUpdate(null)"
> >
<template #main> <template #main>
<div <div class="flex flex-col gap-4 min-w-[280px] sm:min-w-[400px]">
v-if="mutatedParsedChangelog" <!-- iframe for changelog if available -->
class="text-16px sm:text-18px prose prose-a:text-unraid-red hover:prose-a:no-underline hover:prose-a:text-unraid-red/60 dark:prose-a:text-orange hover:dark:prose-a:text-orange/60" <div v-if="docsChangelogUrl" class="w-[calc(100%+3rem)] h-[475px] -mx-6 -my-6">
v-html="mutatedParsedChangelog" <iframe
/> ref="iframeRef"
:src="docsChangelogUrl"
<div v-else-if="parseChangelogFailed" class="text-center flex flex-col gap-4 prose"> class="w-full h-full border-0 rounded-md"
<h2 class="text-lg text-unraid-red italic font-semibold"> sandbox="allow-scripts allow-same-origin"
{{ props.t(`Error Parsing Changelog • {0}`, [parseChangelogFailed]) }} title="Unraid Changelog"
</h2> ></iframe>
<p>
{{ props.t(`It's highly recommended to review the changelog before continuing your update`) }}
</p>
<div v-if="releaseForUpdate?.changelogPretty" class="flex self-center">
<BrandButton
:href="releaseForUpdate?.changelogPretty"
variant="underline"
:external="true"
:icon-right="ArrowTopRightOnSquareIcon"
>
{{ props.t('View Changelog on Docs') }}
</BrandButton>
</div> </div>
</div>
<div <!-- Fallback to raw changelog -->
v-else <RawChangelogRenderer
class="text-center flex flex-col justify-center w-full min-h-[250px] min-w-[280px] sm:min-w-[400px]" v-else-if="showRawChangelog && releaseForUpdate?.changelog"
> :changelog="releaseForUpdate?.changelog"
<BrandLoading class="w-[150px] mx-auto mt-24px" /> :version="releaseForUpdate?.version"
<p>{{ props.t('Fetching & parsing changelog…') }}</p> :date="releaseForUpdate?.date"
:t="t"
:changelog-pretty="releaseForUpdate?.changelogPretty"
/>
<!-- Loading state -->
<div
v-else
class="text-center flex flex-col justify-center w-full min-h-[250px] min-w-[280px] sm:min-w-[400px]"
>
<BrandLoading class="w-[150px] mx-auto mt-24px" />
<p>{{ props.t('Loading changelog…') }}</p>
</div>
</div> </div>
</template> </template>
<template #footer> <template #footer>
<div class="flex flex-col-reverse xs:flex-row justify-between gap-12px md:gap-16px"> <div class="flex flex-col-reverse xs:flex-row justify-between gap-12px md:gap-16px">
<div class="flex flex-col-reverse xs:flex-row xs:justify-start gap-12px md:gap-16px"> <div class="flex flex-col-reverse xs:flex-row xs:justify-start gap-12px md:gap-16px">
<!-- Back to changelog button (when navigated away) -->
<BrandButton <BrandButton
v-if="hasNavigated && docsChangelogUrl"
variant="underline" variant="underline"
:icon="XMarkIcon" :icon="ArrowLeftIcon"
@click="updateOsChangelogStore.setReleaseForUpdate(null)" aria-label="Back to Changelog"
> @click="revertToInitialChangelog"
{{ props.t('Close') }} />
</BrandButton>
<!-- View on docs button -->
<BrandButton <BrandButton
v-if="releaseForUpdate?.changelogPretty" v-if="currentIframeUrl || releaseForUpdate?.changelogPretty"
variant="underline" variant="underline"
:external="true" :external="true"
:href="releaseForUpdate?.changelogPretty" :href="currentIframeUrl || releaseForUpdate?.changelogPretty"
:icon="EyeIcon" :icon="ArrowTopRightOnSquareIcon"
:icon-right="ArrowTopRightOnSquareIcon" aria-label="View on Docs"
> />
{{ props.t('View on Docs') }}
</BrandButton>
</div> </div>
<!-- Action buttons -->
<BrandButton <BrandButton
v-if="showExtendKeyButton" v-if="showExtendKeyButton"
variant="fill" variant="fill"
@@ -122,8 +178,8 @@ const showExtendKeyButton = computed(() => {
<BrandButton <BrandButton
v-else-if="releaseForUpdate?.sha256" v-else-if="releaseForUpdate?.sha256"
:icon="ServerStackIcon" :icon="ServerStackIcon"
:icon-right="ArrowSmallRightIcon" :icon-right="ArrowRightIcon"
@click="updateOsChangelogStore.fetchAndConfirmInstall(releaseForUpdate.sha256)" @click="fetchAndConfirmInstall(releaseForUpdate.sha256)"
> >
{{ props.t('Continue') }} {{ props.t('Continue') }}
</BrandButton> </BrandButton>

View File

@@ -20,7 +20,6 @@ import { useAccountStore } from '~/store/account';
import { usePurchaseStore } from '~/store/purchase'; import { usePurchaseStore } from '~/store/purchase';
import { useServerStore } from '~/store/server'; import { useServerStore } from '~/store/server';
import { useUpdateOsStore } from '~/store/updateOs'; import { useUpdateOsStore } from '~/store/updateOs';
import { useUpdateOsChangelogStore } from '~/store/updateOsChangelog';
export interface Props { export interface Props {
open?: boolean; open?: boolean;
@@ -35,7 +34,6 @@ const accountStore = useAccountStore();
const purchaseStore = usePurchaseStore(); const purchaseStore = usePurchaseStore();
const serverStore = useServerStore(); const serverStore = useServerStore();
const updateOsStore = useUpdateOsStore(); const updateOsStore = useUpdateOsStore();
const updateOsChangelogStore = useUpdateOsChangelogStore();
const { const {
regExp, regExp,
@@ -181,7 +179,7 @@ const actionButtons = computed((): BrandButtonProps[] | null => {
buttons.push({ buttons.push({
variant: availableWithRenewal.value ? 'outline' : undefined, variant: availableWithRenewal.value ? 'outline' : undefined,
click: async () => click: async () =>
await updateOsChangelogStore.setReleaseForUpdate(updateOsResponse.value ?? null), await updateOsStore.setReleaseForUpdate(updateOsResponse.value ?? null),
icon: EyeIcon, icon: EyeIcon,
text: availableWithRenewal.value text: availableWithRenewal.value
? props.t('View Changelog') ? props.t('View Changelog')

View File

@@ -0,0 +1,141 @@
<script setup lang="ts">
import { computed, ref, onMounted, watch } from 'vue';
import { Markdown } from '@/helpers/markdown';
import { BrandButton } from '@unraid/ui';
import { ArrowTopRightOnSquareIcon } from '@heroicons/vue/24/solid';
import type { ComposerTranslation } from 'vue-i18n';
const props = defineProps<{
changelog: string; // This is the URL to the changelog, not the content
version?: string;
date?: string;
t: ComposerTranslation;
changelogPretty?: string;
}>();
const parsedChangelog = ref<string>("");
const parsedChangelogTitle = ref<string>("");
const parseChangelogFailed = ref<string>("");
const isLoading = ref<boolean>(false);
// Fetch and parse the changelog using the same logic as in updateOsChangelog.ts
const fetchAndParseChangelog = async () => {
if (!props.changelog) {
isLoading.value = false;
parseChangelogFailed.value = 'No changelog URL provided';
return;
}
isLoading.value = true;
parseChangelogFailed.value = "";
try {
// Fetch the changelog content from the URL
const res = await fetch(props.changelog);
if (!res.ok) {
throw new Error(`HTTP ${res.status} while fetching changelog`);
}
const data = await res.text();
if (!data || data.trim() === "") {
parseChangelogFailed.value = 'Changelog is empty';
isLoading.value = false;
return;
}
let firstHeading = true;
const marked = Markdown.create();
// open links in new tab & replace .md from links
const renderer = new marked.Renderer();
// Set base URL for relative links
const baseUrl = "https://docs.unraid.net/go/release-notes/";
renderer.link = ({ href, title, tokens }) => {
const linkText = renderer.parser.parseInline(tokens);
let cleanHref = href.replace(".md", ""); // remove .md from href
if (cleanHref.startsWith("#")) {
cleanHref = `${baseUrl}${props.version}${cleanHref}`;
}
if (!cleanHref.startsWith("http")) {
cleanHref = `${baseUrl}${cleanHref}`;
}
return `<a href="${cleanHref}" ${title ? `title="${title}"` : ""} target="_blank" rel="noopener noreferrer">${linkText}</a>`;
};
// Add heading renderer
renderer.heading = ({ text, depth }) => {
// Capture the first h1 title
if (depth === 1 && firstHeading) {
firstHeading = false;
parsedChangelogTitle.value = `Version ${props.version} ${props.date ? `(${props.date})` : ''}`;
return `<br />`;
}
// Only add IDs for h2 and above (depth > 1)
if (depth > 1) {
const id = text
.toLowerCase()
.replace(/[^\w\s-]/g, "")
.replace(/\s+/g, "-");
return `<h${depth} id="${id}">${text}</h${depth}>`;
}
return `<h${depth}>${text}</h${depth}>`;
};
marked.setOptions({
renderer,
});
parsedChangelog.value = await marked.parse(
(data as string) ?? "Changelog fetch data empty"
);
} catch (error: unknown) {
const caughtError = error as Error;
parseChangelogFailed.value =
caughtError && caughtError?.message
? caughtError.message
: `Failed to parse ${props.version} changelog`;
} finally {
isLoading.value = false;
}
};
// Parse the changelog when the component is mounted or props change
onMounted(fetchAndParseChangelog);
watch(() => props.changelog, fetchAndParseChangelog, { immediate: true });
// Used to remove the first <h1></h1> and its contents from the parsedChangelog
const mutatedParsedChangelog = computed(() => {
if (parsedChangelog.value) {
return parsedChangelog.value.replace(/<h1>(.*?)<\/h1>/, "");
}
return parsedChangelog.value;
});
</script>
<template>
<div class="prose prose-sm dark:prose-invert max-w-none markdown-body p-4 overflow-auto">
<div v-if="parseChangelogFailed" class="text-center flex flex-col gap-4 prose">
<h2 class="text-lg text-unraid-red italic font-semibold">
{{ props.t(`Error Parsing Changelog • {0}`, [parseChangelogFailed]) }}
</h2>
<p>
{{
props.t(`It's highly recommended to review the changelog before continuing your update`)
}}
</p>
<div v-if="props.changelogPretty" class="flex self-center">
<BrandButton
:href="props.changelogPretty"
variant="underline"
:external="true"
:icon-right="ArrowTopRightOnSquareIcon"
>
{{ props.t('View Changelog on Docs') }}
</BrandButton>
</div>
</div>
<div v-else-if="parsedChangelogTitle" class="mb-4">
<h1>{{ parsedChangelogTitle }}</h1>
</div>
<div v-if="mutatedParsedChangelog" v-html="mutatedParsedChangelog"></div>
<div v-else-if="isLoading" class="flex flex-col items-center justify-center py-8">
<span class="mx-auto animate-spin border-2 border-gray-300 rounded-full w-8 h-8 border-t-unraid-red"></span>
<p class="ml-2">{{ props.t('Loading changelog...') }}</p>
</div>
<div v-else class="text-center py-8">
<p>{{ props.t('No changelog content available') }}</p>
</div>
</div>
</template>

View File

@@ -40,6 +40,9 @@ const DOCS_REGISTRATION_REPLACE_KEY = new URL('/go/changing-the-flash-device/',
const SUPPORT = new URL('https://unraid.net'); const SUPPORT = new URL('https://unraid.net');
export const allowedDocsOriginRegex = /^https:\/\/(?:[\w-]+\.)*docs\.unraid\.net(?::\d+)?$/;
export const allowedDocsUrlRegex = /^https:\/\/(?:[\w-]+\.)*docs\.unraid\.net(?::\d+)?\//;
export { export {
ACCOUNT, ACCOUNT,
ACCOUNT_CALLBACK, ACCOUNT_CALLBACK,

View File

@@ -384,5 +384,6 @@
"Your Trial key has been extended!": "Your Trial key has been extended!", "Your Trial key has been extended!": "Your Trial key has been extended!",
"Create an Unraid.net account and activate your key": "Create an Unraid.net account and activate your key", "Create an Unraid.net account and activate your key": "Create an Unraid.net account and activate your key",
"Device is ready to configure": "Device is ready to configure", "Device is ready to configure": "Device is ready to configure",
"Secure your device": "Secure your device" "Secure your device": "Secure your device",
"Unraid OS {0} Changelog": "Unraid OS {0} Changelog"
} }

View File

@@ -1,50 +1,88 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useUpdateOsChangelogStore } from '~/store/updateOsChangelog'; import { useUpdateOsStore } from '~/store/updateOs';
import { storeToRefs } from 'pinia';
const changelog = ref(''); const updateOsStore = useUpdateOsStore();
const updateOsChangelogStore = useUpdateOsChangelogStore(); const { changelogModalVisible } = storeToRefs(updateOsStore);
const { releaseForUpdate: updateOsChangelogModalVisible } = storeToRefs(updateOsChangelogStore); const { t } = useI18n();
onMounted(async () => {
const response = await fetch('https://releases.unraid.net/json');
async function showChangelogModalFromReleasesEndpoint() {
const response = await fetch('https://releases.unraid.net/os?branch=stable&current_version=6.12.3');
const data = await response.json(); const data = await response.json();
if (data.length > 0) { updateOsStore.setReleaseForUpdate(data);
changelog.value = data[0].changelog; }
}
});
function showChangelogModal() { function showChangelogModalWithTestData() {
// Simulate receiving a release for update updateOsStore.setReleaseForUpdate({
updateOsChangelogStore.setReleaseForUpdate({
version: '6.12.3', version: '6.12.3',
date: '2023-07-15', date: '2023-07-15',
changelog: changelog.value, changelog: 'https://raw.githubusercontent.com/unraid/docs/main/docs/unraid-os/release-notes/6.12.3.md',
changelogPretty: 'https://docs.unraid.net/go/release-notes/6.12.3',
name: '6.12.3', name: '6.12.3',
isEligible: true, isEligible: true,
isNewer: true, isNewer: true,
sha256: '1234567890'
});
}
function showChangelogWithoutPretty() {
updateOsStore.setReleaseForUpdate({
version: '6.12.3',
date: '2023-07-15',
changelog: 'https://raw.githubusercontent.com/unraid/docs/main/docs/unraid-os/release-notes/6.12.3.md',
changelogPretty: '',
name: '6.12.3',
isEligible: true,
isNewer: true,
sha256: '1234567890'
});
}
function showChangelogBrokenParse() {
updateOsStore.setReleaseForUpdate({
version: '6.12.3',
date: '2023-07-15',
changelog: null,
changelogPretty: undefined, // intentionally broken
name: '6.12.3',
isEligible: true,
isNewer: true,
sha256: '1234567890' sha256: '1234567890'
}); });
} }
const { t } = useI18n();
</script> </script>
<template> <template>
<div class="container mx-auto p-6"> <div class="container mx-auto p-6">
<h1 class="text-2xl font-bold mb-6">Changelog</h1> <h1 class="text-2xl font-bold mb-6">Changelog</h1>
<UpdateOsChangelogModal :t="t" :open="!!updateOsChangelogModalVisible" /> <UpdateOsChangelogModal :t="t" :open="changelogModalVisible" />
<div class="mb-6"> <div class="mb-6 flex flex-col gap-4 max-w-md">
<button <button
class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600" class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
@click="showChangelogModal" @click="showChangelogModalFromReleasesEndpoint"
> >
Test Changelog Modal Test Changelog Modal (from releases endpoint)
</button>
<button
class="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
@click="showChangelogModalWithTestData"
>
Test Changelog Modal (with test data)
</button>
<button
class="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600"
@click="showChangelogWithoutPretty"
>
Test Without Pretty Changelog
</button>
<button
class="px-4 py-2 bg-yellow-500 text-white rounded hover:bg-yellow-600"
@click="showChangelogBrokenParse"
>
Test Broken Parse Changelog
</button> </button>
</div>
<div class="prose max-w-none">
<div v-html="changelog"></div>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -9,6 +9,8 @@ import type { ServerUpdateOsResponse } from '~/types/server';
import { WebguiCheckForUpdate, WebguiUpdateCancel } from '~/composables/services/webgui'; import { WebguiCheckForUpdate, WebguiUpdateCancel } from '~/composables/services/webgui';
import { useServerStore } from '~/store/server'; import { useServerStore } from '~/store/server';
import prerelease from 'semver/functions/prerelease';
import { useCallbackActionsStore } from '~/store/callbackActions';
/** /**
* @see https://stackoverflow.com/questions/73476371/using-pinia-with-vue-js-web-components * @see https://stackoverflow.com/questions/73476371/using-pinia-with-vue-js-web-components
@@ -23,7 +25,9 @@ extend(relativeTime);
export const useUpdateOsStore = defineStore('updateOs', () => { export const useUpdateOsStore = defineStore('updateOs', () => {
// state // state
const checkForUpdatesLoading = ref<boolean>(false); const checkForUpdatesLoading = ref<boolean>(false);
const modalOpen = ref<boolean>(false); const updateOsModalVisible = ref<boolean>(false);
const releaseForUpdate = ref<ServerUpdateOsResponse | null>(null);
const changelogModalVisible = computed(() => !!releaseForUpdate.value);
// getters from other stores // getters from other stores
const serverStore = useServerStore(); const serverStore = useServerStore();
@@ -59,6 +63,33 @@ export const useUpdateOsStore = defineStore('updateOs', () => {
*/ */
const availableRequiresAuth = computed((): boolean => !updateOsResponse.value?.sha256); const availableRequiresAuth = computed((): boolean => !updateOsResponse.value?.sha256);
// Changelog logic
const changelogUrl = computed((): string => releaseForUpdate.value?.changelog || '');
const changelogPretty = computed(() => releaseForUpdate.value?.changelogPretty ?? null);
const setReleaseForUpdate = (release: ServerUpdateOsResponse | null) => {
releaseForUpdate.value = release;
};
// isReleaseForUpdateStable logic (true if no prerelease in version)
const isReleaseForUpdateStable = computed(() => {
if (!releaseForUpdate.value?.version) return false;
return !prerelease(releaseForUpdate.value.version);
});
// fetchAndConfirmInstall logic
const callbackStore = useCallbackActionsStore();
const fetchAndConfirmInstall = (sha256: string) => {
callbackStore.send(
window.location.href,
[
{
sha256,
type: 'updateOs',
},
],
undefined,
'forUpc'
);
};
// actions // actions
const localCheckForUpdate = async (): Promise<void> => { const localCheckForUpdate = async (): Promise<void> => {
checkForUpdatesLoading.value = true; checkForUpdatesLoading.value = true;
@@ -95,7 +126,7 @@ export const useUpdateOsStore = defineStore('updateOs', () => {
}; };
const setModalOpen = (val: boolean) => { const setModalOpen = (val: boolean) => {
modalOpen.value = val; updateOsModalVisible.value = val;
}; };
return { return {
@@ -103,14 +134,21 @@ export const useUpdateOsStore = defineStore('updateOs', () => {
available, available,
availableWithRenewal, availableWithRenewal,
checkForUpdatesLoading, checkForUpdatesLoading,
modalOpen, updateOsModalVisible,
changelogModalVisible,
releaseForUpdate,
updateOsIgnoredReleases, updateOsIgnoredReleases,
// getters // getters
availableReleaseDate, availableReleaseDate,
availableRequiresAuth, availableRequiresAuth,
changelogUrl,
changelogPretty,
isReleaseForUpdateStable,
// actions // actions
localCheckForUpdate, localCheckForUpdate,
cancelUpdate, cancelUpdate,
setModalOpen, setModalOpen,
setReleaseForUpdate,
fetchAndConfirmInstall,
}; };
}); });

View File

@@ -1,132 +0,0 @@
import { computed, ref, watch } from 'vue';
import { defineStore } from 'pinia';
import { Markdown } from '@/helpers/markdown';
import { DOCS_RELEASE_NOTES } from '~/helpers/urls';
import { baseUrl } from 'marked-base-url';
import prerelease from 'semver/functions/prerelease';
import type { ServerUpdateOsResponse } from '~/types/server';
import { request } from '~/composables/services/request';
import { useCallbackActionsStore } from '~/store/callbackActions';
export const useUpdateOsChangelogStore = defineStore('updateOsChangelog', () => {
const callbackStore = useCallbackActionsStore();
const releaseForUpdate = ref<ServerUpdateOsResponse | null>(null);
watch(releaseForUpdate, async (newVal, oldVal) => {
console.debug('[releaseForUpdate] watch', newVal, oldVal);
resetChangelogDetails(); // reset values when setting and unsetting a selected release
// Fetch and parse the changelog when the user selects a release
if (newVal) {
await fetchAndParseChangelog();
}
});
const changelogUrl = computed((): string => {
if (!releaseForUpdate.value || !releaseForUpdate.value?.changelog) {
return '';
}
return (
releaseForUpdate.value?.changelog ??
`https://raw.githubusercontent.com/unraid/docs/main/docs/unraid-os/release-notes/${releaseForUpdate.value.version}.md`
);
});
const isReleaseForUpdateStable = computed(() =>
releaseForUpdate.value ? prerelease(releaseForUpdate.value.version) === null : false
);
const parsedChangelog = ref<string>('');
const parseChangelogFailed = ref<string>('');
// used to remove the first <h1></h1> and it's contents from the parsedChangelog
const mutatedParsedChangelog = computed(() => {
if (parsedChangelog.value) {
return parsedChangelog.value.replace(/<h1>(.*?)<\/h1>/, '');
}
return parsedChangelog.value;
});
// used to extract the first <h1></h1> and it's contents from the parsedChangelog for the modal header title
const parsedChangelogTitle = computed(() => {
if (parseChangelogFailed.value) {
return parseChangelogFailed.value;
}
if (parsedChangelog.value) {
return (
parsedChangelog.value.match(/<h1>(.*?)<\/h1>/)?.[1] ??
`Version ${releaseForUpdate.value?.version} ${releaseForUpdate.value?.date}`
);
}
return '';
});
const setReleaseForUpdate = (release: ServerUpdateOsResponse | null) => {
console.debug('[setReleaseForUpdate]', release);
releaseForUpdate.value = release;
};
const resetChangelogDetails = () => {
console.debug('[resetChangelogDetails]');
parsedChangelog.value = '';
parseChangelogFailed.value = '';
};
const fetchAndParseChangelog = async () => {
console.debug('[fetchAndParseChangelog]');
try {
const changelogMarkdownRaw = await request
.url(changelogUrl.value ?? '')
.get()
.text();
// set base url for relative links
const marked = Markdown.create(baseUrl(DOCS_RELEASE_NOTES.toString()));
// open links in new tab & replace .md from links
const renderer = new marked.Renderer();
renderer.link = ({ href, title, tokens }) => {
const linkText = renderer.parser.parseInline(tokens);
const cleanHref = href.replace('.md', ''); // remove .md from href
return `<a href="${cleanHref}" ${title ? `title="${title}"` : ''} target="_blank">${linkText}</a>`;
};
marked.setOptions({
renderer,
});
parsedChangelog.value = await marked.parse(changelogMarkdownRaw);
} catch (error: unknown) {
const caughtError = error as Error;
parseChangelogFailed.value =
caughtError && caughtError?.message
? caughtError.message
: `Failed to parse ${releaseForUpdate.value?.version} changelog`;
}
};
const fetchAndConfirmInstall = (sha256: string) => {
callbackStore.send(
window.location.href,
[
{
sha256,
type: 'updateOs',
},
],
undefined,
'forUpc'
);
};
return {
// state
parseChangelogFailed,
releaseForUpdate,
// getters
isReleaseForUpdateStable,
mutatedParsedChangelog,
parsedChangelogTitle,
// actions
setReleaseForUpdate,
fetchAndConfirmInstall,
};
});