chore: remove audit file report (#17994)
This commit is contained in:
@@ -1,358 +0,0 @@
|
||||
<script lang="ts">
|
||||
import empty4Url from '$lib/assets/empty-4.svg';
|
||||
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
||||
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
|
||||
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
|
||||
import {
|
||||
NotificationType,
|
||||
notificationController,
|
||||
} from '$lib/components/shared-components/notification/notification';
|
||||
import { downloadManager } from '$lib/managers/download-manager.svelte';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { copyToClipboard } from '$lib/utils';
|
||||
import { downloadBlob } from '$lib/utils/asset-utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { fixAuditFiles, getAuditFiles, getFileChecksums, type FileReportItemDto } from '@immich/sdk';
|
||||
import { Button, HStack, Text } from '@immich/ui';
|
||||
import { mdiCheckAll, mdiContentCopy, mdiDownload, mdiRefresh, mdiWrench } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
interface Props {
|
||||
data: PageData;
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
interface UntrackedFile {
|
||||
filename: string;
|
||||
checksum: string | null;
|
||||
}
|
||||
|
||||
interface Match {
|
||||
orphan: FileReportItemDto;
|
||||
extra: UntrackedFile;
|
||||
}
|
||||
|
||||
const normalize = (filenames: string[]) => filenames.map((filename) => ({ filename, checksum: null }));
|
||||
|
||||
let checking = $state(false);
|
||||
let repairing = $state(false);
|
||||
|
||||
let orphans: FileReportItemDto[] = $state(data.orphans);
|
||||
let extras: UntrackedFile[] = $state(normalize(data.extras));
|
||||
let matches: Match[] = $state([]);
|
||||
|
||||
const handleDownload = () => {
|
||||
if (extras.length > 0) {
|
||||
const blob = new Blob([extras.map(({ filename }) => filename).join('\n')], { type: 'text/plain' });
|
||||
const downloadKey = 'untracked.txt';
|
||||
downloadManager.add(downloadKey, blob.size);
|
||||
downloadManager.update(downloadKey, blob.size);
|
||||
downloadBlob(blob, downloadKey);
|
||||
setTimeout(() => downloadManager.clear(downloadKey), 5000);
|
||||
}
|
||||
|
||||
if (orphans.length > 0) {
|
||||
const blob = new Blob([JSON.stringify(orphans, null, 4)], { type: 'application/json' });
|
||||
const downloadKey = 'orphans.json';
|
||||
downloadManager.add(downloadKey, blob.size);
|
||||
downloadManager.update(downloadKey, blob.size);
|
||||
downloadBlob(blob, downloadKey);
|
||||
setTimeout(() => downloadManager.clear(downloadKey), 5000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRepair = async () => {
|
||||
if (matches.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
repairing = true;
|
||||
|
||||
try {
|
||||
await fixAuditFiles({
|
||||
fileReportFixDto: {
|
||||
items: matches.map(({ orphan, extra }) => ({
|
||||
entityId: orphan.entityId,
|
||||
entityType: orphan.entityType,
|
||||
pathType: orphan.pathType,
|
||||
pathValue: extra.filename,
|
||||
})),
|
||||
},
|
||||
});
|
||||
|
||||
notificationController.show({
|
||||
type: NotificationType.Info,
|
||||
message: $t('admin.repaired_items', { values: { count: matches.length } }),
|
||||
});
|
||||
|
||||
matches = [];
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_repair_items'));
|
||||
} finally {
|
||||
repairing = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSplit = (match: Match) => {
|
||||
matches = matches.filter((_match) => _match !== match);
|
||||
orphans = [match.orphan, ...orphans];
|
||||
extras = [match.extra, ...extras];
|
||||
};
|
||||
|
||||
const handleRefresh = async () => {
|
||||
matches = [];
|
||||
orphans = [];
|
||||
extras = [];
|
||||
|
||||
try {
|
||||
const report = await getAuditFiles();
|
||||
|
||||
orphans = report.orphans;
|
||||
extras = normalize(report.extras);
|
||||
|
||||
notificationController.show({ message: $t('refreshed'), type: NotificationType.Info });
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_load_items'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckOne = async (filename: string) => {
|
||||
try {
|
||||
const matched = await loadAndMatch([filename]);
|
||||
if (matched) {
|
||||
notificationController.show({
|
||||
message: $t('admin.repair_matched_items', { values: { count: 1 } }),
|
||||
type: NotificationType.Info,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.repair_unable_to_check_items', { values: { count: 'one' } }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckAll = async () => {
|
||||
checking = true;
|
||||
|
||||
let count = 0;
|
||||
|
||||
try {
|
||||
const chunkSize = 10;
|
||||
const filenames = extras.filter(({ checksum }) => !checksum).map(({ filename }) => filename);
|
||||
for (let index = 0; index < filenames.length; index += chunkSize) {
|
||||
count += await loadAndMatch(filenames.slice(index, index + chunkSize));
|
||||
}
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.repair_unable_to_check_items', { values: { count: 'other' } }));
|
||||
} finally {
|
||||
checking = false;
|
||||
}
|
||||
|
||||
notificationController.show({
|
||||
message: $t('admin.repair_matched_items', { values: { count } }),
|
||||
type: NotificationType.Info,
|
||||
});
|
||||
};
|
||||
|
||||
const loadAndMatch = async (filenames: string[]) => {
|
||||
const items = await getFileChecksums({
|
||||
fileChecksumDto: { filenames },
|
||||
});
|
||||
|
||||
let count = 0;
|
||||
|
||||
for (const { checksum, filename } of items) {
|
||||
const extra = extras.find((extra) => extra.filename === filename);
|
||||
if (extra) {
|
||||
extra.checksum = checksum;
|
||||
extras = [...extras];
|
||||
}
|
||||
|
||||
const orphan = orphans.find((orphan) => orphan.checksum === checksum);
|
||||
if (orphan) {
|
||||
count++;
|
||||
matches = [...matches, { orphan, extra: { filename, checksum } }];
|
||||
orphans = orphans.filter((_orphan) => _orphan !== orphan);
|
||||
extras = extras.filter((extra) => extra.filename !== filename);
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
};
|
||||
</script>
|
||||
|
||||
<UserPageLayout title={data.meta.title} admin>
|
||||
{#snippet buttons()}
|
||||
<HStack gap={0}>
|
||||
<Button
|
||||
leadingIcon={mdiWrench}
|
||||
onclick={() => handleRepair()}
|
||||
disabled={matches.length === 0 || repairing}
|
||||
size="small"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
>
|
||||
<Text class="hidden md:block">{$t('admin.repair_all')}</Text>
|
||||
</Button>
|
||||
<Button
|
||||
leadingIcon={mdiCheckAll}
|
||||
onclick={() => handleCheckAll()}
|
||||
disabled={extras.length === 0 || checking}
|
||||
size="small"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
>
|
||||
<Text class="hidden md:block">{$t('admin.check_all')}</Text>
|
||||
</Button>
|
||||
<Button
|
||||
leadingIcon={mdiDownload}
|
||||
onclick={() => handleDownload()}
|
||||
disabled={extras.length + orphans.length === 0}
|
||||
size="small"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
>
|
||||
<Text class="hidden md:block">{$t('export')}</Text>
|
||||
</Button>
|
||||
<Button leadingIcon={mdiRefresh} onclick={() => handleRefresh()} size="small" variant="ghost" color="secondary">
|
||||
<Text class="hidden md:block">{$t('refresh')}</Text>
|
||||
</Button>
|
||||
</HStack>
|
||||
{/snippet}
|
||||
<section id="setting-content" class="flex place-content-center sm:mx-4">
|
||||
<section class="w-full pb-28 sm:w-5/6 md:w-[850px]">
|
||||
{#if matches.length + extras.length + orphans.length === 0}
|
||||
<div class="w-full">
|
||||
<EmptyPlaceholder fullWidth text={$t('repair_no_results_message')} src={empty4Url} />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="gap-2">
|
||||
<table class="table-fixed mt-5 w-full text-start">
|
||||
<thead
|
||||
class="mb-4 flex w-full rounded-md border bg-gray-50 text-immich-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-primary"
|
||||
>
|
||||
<tr class="flex w-full place-items-center p-2 md:p-5">
|
||||
<th class="w-full text-sm place-items-center font-medium flex justify-between" colspan="2">
|
||||
<div class="px-3">
|
||||
<p>
|
||||
{$t('matches').toUpperCase()}
|
||||
{matches.length > 0 ? `(${matches.length.toLocaleString($locale)})` : ''}
|
||||
</p>
|
||||
<p class="text-gray-600 dark:text-gray-300 mt-1">{$t('admin.these_files_matched_by_checksum')}</p>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody
|
||||
class="w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray dark:text-immich-dark-fg max-h-[500px] block overflow-x-hidden"
|
||||
>
|
||||
{#each matches as match (match.extra.filename)}
|
||||
<tr
|
||||
class="w-full h-[75px] place-items-center border-[3px] border-transparent p-2 odd:bg-immich-gray even:bg-immich-bg hover:cursor-pointer hover:border-immich-primary/75 odd:dark:bg-immich-dark-gray/75 even:dark:bg-immich-dark-gray/50 dark:hover:border-immich-dark-primary/75 md:p-5 flex justify-between"
|
||||
tabindex="0"
|
||||
onclick={() => handleSplit(match)}
|
||||
>
|
||||
<td class="text-sm text-ellipsis flex flex-col gap-1 font-mono">
|
||||
<span>{match.orphan.pathValue} =></span>
|
||||
<span>{match.extra.filename}</span>
|
||||
</td>
|
||||
<td class="text-sm text-ellipsis d-flex font-mono">
|
||||
<span>({match.orphan.entityType}/{match.orphan.pathType})</span>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<table class="table-fixed mt-5 w-full text-start">
|
||||
<thead
|
||||
class="mb-4 flex w-full rounded-md border bg-gray-50 text-immich-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-primary"
|
||||
>
|
||||
<tr class="flex w-full place-items-center p-1 md:p-5">
|
||||
<th class="w-full text-sm font-medium justify-between place-items-center flex" colspan="2">
|
||||
<div class="px-3">
|
||||
<p>
|
||||
{$t('admin.offline_paths').toUpperCase()}
|
||||
{orphans.length > 0 ? `(${orphans.length.toLocaleString($locale)})` : ''}
|
||||
</p>
|
||||
<p class="text-gray-600 dark:text-gray-300 mt-1">
|
||||
{$t('admin.offline_paths_description')}
|
||||
</p>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody
|
||||
class="w-full rounded-md border dark:border-immich-dark-gray dark:text-immich-dark-fg overflow-y-auto max-h-[500px] block overflow-x-hidden"
|
||||
>
|
||||
{#each orphans as orphan, index (index)}
|
||||
<tr
|
||||
class="w-full h-[50px] place-items-center border-[3px] border-transparent odd:bg-immich-gray even:bg-immich-bg hover:cursor-pointer hover:border-immich-primary/75 odd:dark:bg-immich-dark-gray/75 even:dark:bg-immich-dark-gray/50 dark:hover:border-immich-dark-primary/75 md:p-5 flex justify-between"
|
||||
tabindex="0"
|
||||
title={orphan.pathValue}
|
||||
>
|
||||
<td onclick={() => copyToClipboard(orphan.pathValue)}>
|
||||
<CircleIconButton title={$t('copy_file_path')} icon={mdiContentCopy} size="18" onclick={() => {}} />
|
||||
</td>
|
||||
<td class="truncate text-sm font-mono text-start" title={orphan.pathValue}>
|
||||
{orphan.pathValue}
|
||||
</td>
|
||||
<td class="text-sm font-mono">
|
||||
<span>({orphan.entityType})</span>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<table class="table-fixed mt-5 w-full text-start max-h-[300px]">
|
||||
<thead
|
||||
class="mb-4 flex w-full rounded-md border bg-gray-50 text-immich-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-primary"
|
||||
>
|
||||
<tr class="flex w-full place-items-center p-2 md:p-5">
|
||||
<th class="w-full text-sm font-medium place-items-center flex justify-between" colspan="2">
|
||||
<div class="px-3">
|
||||
<p>
|
||||
{$t('admin.untracked_files').toUpperCase()}
|
||||
{extras.length > 0 ? `(${extras.length.toLocaleString($locale)})` : ''}
|
||||
</p>
|
||||
<p class="text-gray-600 dark:text-gray-300 mt-1">
|
||||
{$t('admin.untracked_files_description')}
|
||||
</p>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody
|
||||
class="w-full rounded-md border-2 dark:border-immich-dark-gray dark:text-immich-dark-fg overflow-y-auto max-h-[500px] block overflow-x-hidden"
|
||||
>
|
||||
{#each extras as extra (extra.filename)}
|
||||
<tr
|
||||
class="flex h-[50px] w-full place-items-center border-[3px] border-transparent p-1 odd:bg-immich-gray even:bg-immich-bg hover:cursor-pointer hover:border-immich-primary/75 odd:dark:bg-immich-dark-gray/75 even:dark:bg-immich-dark-gray/50 dark:hover:border-immich-dark-primary/75 md:p-5 justify-between"
|
||||
tabindex="0"
|
||||
onclick={() => handleCheckOne(extra.filename)}
|
||||
title={extra.filename}
|
||||
>
|
||||
<td onclick={() => copyToClipboard(extra.filename)}>
|
||||
<CircleIconButton title={$t('copy_file_path')} icon={mdiContentCopy} size="18" onclick={() => {}} />
|
||||
</td>
|
||||
<td class="w-full text-md text-ellipsis flex justify-between pe-5">
|
||||
<span class="text-ellipsis grow truncate font-mono text-sm pe-5" title={extra.filename}
|
||||
>{extra.filename}</span
|
||||
>
|
||||
<span class="text-sm font-mono dark:text-immich-dark-primary text-immich-primary pes-5">
|
||||
{#if extra.checksum}
|
||||
[sha1:{extra.checksum}]
|
||||
{/if}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
</section>
|
||||
</UserPageLayout>
|
||||
@@ -1,18 +0,0 @@
|
||||
import { authenticate } from '$lib/utils/auth';
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import { getAuditFiles } from '@immich/sdk';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (async () => {
|
||||
await authenticate({ admin: true });
|
||||
const { orphans, extras } = await getAuditFiles();
|
||||
const $t = await getFormatter();
|
||||
|
||||
return {
|
||||
orphans,
|
||||
extras,
|
||||
meta: {
|
||||
title: $t('repair'),
|
||||
},
|
||||
};
|
||||
}) satisfies PageLoad;
|
||||
Reference in New Issue
Block a user