diff --git a/src/components/compute/TaskDetailPanel.vue b/src/components/compute/TaskDetailPanel.vue index ff959a0d..63e647fa 100644 --- a/src/components/compute/TaskDetailPanel.vue +++ b/src/components/compute/TaskDetailPanel.vue @@ -17,6 +17,7 @@ import CountedList from '@/components/ui/CountedList.vue' import Pagination from '@/components/ui/Pagination.vue' import DocsLink from '@/components/ui/DocsLink.vue' import JobExecutionsTable from '@/components/jobs/JobExecutionsTable.vue' +import JobKeyWait from '@/components/jobs/JobKeyWait.vue' import JobPlacementFigure from '@/components/jobs/JobPlacementFigure.vue' import RunLogDialog, { type LogStream } from '@/components/compute/RunLogDialog.vue' import TaskHeader from '@/components/compute/TaskHeader.vue' @@ -299,9 +300,13 @@ interface Summary { tone: 'error' | 'warning' | 'info' | 'success' headline: string } +// TES has no state for a run that waits for a locked bucket; the native job says so. +const awaitingKey = computed(() => nativeJob.value?.state === 'awaiting_key' && !isTerminalTesState(task.value?.state)) + const summary = computed(() => { const state = task.value?.state if (!state) return null + if (awaitingKey.value) return { tone: 'warning', headline: 'Waiting for a bucket unlock' } const duration = runDuration.value const where = executorKind.value ? ` on ${executorKind.value}` : '' switch (state) { @@ -662,11 +667,12 @@ async function confirmDelete() {

stderr: {{ stderrTail }}

-

+

One node answers and runs it; another steps in after a while without a response.

+

Refresh failed, retrying.

diff --git a/src/components/jobs/JobDetailPanel.vue b/src/components/jobs/JobDetailPanel.vue index 72739319..786cde13 100644 --- a/src/components/jobs/JobDetailPanel.vue +++ b/src/components/jobs/JobDetailPanel.vue @@ -12,6 +12,7 @@ import CopyButton from '@/components/ui/CopyButton.vue' import JobArtifactButton from '@/components/jobs/JobArtifactButton.vue' import JobAuditTrail from '@/components/jobs/JobAuditTrail.vue' import JobFamilySection from '@/components/jobs/JobFamilySection.vue' +import JobKeyWait from '@/components/jobs/JobKeyWait.vue' import JobReportPanel from '@/components/jobs/JobReportPanel.vue' import JobStateBadge from '@/components/jobs/JobStateBadge.vue' import { useJobDetail } from '@/composables/useJobs' @@ -96,6 +97,7 @@ async function confirmCancel() {
+

Progress

() => h('aside', `waits for ${JSON.stringify(props.waits)}`) }), + ), '@/components/compute/RunLogDialog.vue': moduleDefault(PassThroughStub), '@/components/compute/TaskHeader.vue': moduleDefault(PassThroughStub), '@/components/assistant/AskAiButton.vue': moduleDefault(PassThroughStub), @@ -328,6 +331,7 @@ function jobPanel(job: JobStatusResponse): Component { '@/components/jobs/JobFamilySection.vue': moduleDefault( defineComponent(() => () => h('section', 'native family detail')), ), + '@/components/jobs/JobKeyWait.vue': moduleDefault(defineComponent(() => () => h('aside', 'waits for an unlock'))), '@/components/jobs/JobReportPanel.vue': moduleDefault(PassThroughStub), '@/components/jobs/JobStateBadge.vue': moduleDefault(JobStateBadgeStub), '@/composables/useJobs': { @@ -390,6 +394,29 @@ describe('distributed job detail components', () => { { destination_key: 'in/config.yaml', bytes: 2048, source_node_id: null, transfer_ms: 0 }, ] + it('tells a job that waits for a bucket key apart from a failed one', async () => { + const waiting: JobStatusResponse = { + job_id: '01JJRSTVWXYZ0123456789ABCE', + kind: 'staging', + state: 'awaiting_key', + attempts: 0, + cancel_requested: false, + created_at: '2026-04-09T14:23:11.123+00:00', + updated_at: '2026-04-09T14:31:47.902+00:00', + progress: { current: 0, unit: 'inputs' }, + workspace_mode: 'none', + awaiting_keys: [{ node_id: 'node-b', bucket: 'reef' }], + } + + const mounted = await mount(jobPanel(waiting), { jobId: waiting.job_id, open: true }) + + expect(mounted.errors).toEqual([]) + expect(content(mounted.root)).toContain('waits for an unlock') + expect(Jobs.JOB_STATE_META.awaiting_key).toEqual({ label: 'Waiting for a key', variant: 'warn' }) + expect(Jobs.isTerminalJobState('awaiting_key')).toBe(false) + mounted.app.unmount() + }) + it('states no workspace detail, whatever the node reports', async () => { // A node may still serve a mode and a bucket; a run owns neither any more. const JobDetailPanel = jobPanel({ @@ -878,6 +905,29 @@ describe('distributed job detail components', () => { mounted.app.unmount() }) + it('says a queued run waits for a bucket unlock when its native job does', async () => { + const getTask = vi.fn(async () => ({ + id: 'waiting-run', + state: 'QUEUED', + executors: [{ image: 'alpine', command: ['sh'] }], + inputs: [], + outputs: [], + logs: [], + tags: {}, + })) + const waits = [{ node_id: 'node-b', bucket: 'reef' }] + const getJob = vi.fn(async () => ({ state: 'awaiting_key', awaiting_keys: waits, family: null })) + + const mounted = await mount(taskPanel(getTask, getJob), { taskId: 'waiting-run', open: true }) + await vi.waitFor(() => expect(content(mounted.root)).toContain('Waiting for a bucket unlock')) + + expect(mounted.errors).toEqual([]) + expect(content(mounted.root)).toContain(`waits for ${JSON.stringify(waits)}`) + expect(content(mounted.root)).not.toContain('Queued, waiting for a node') + expect(content(mounted.root)).not.toContain('another steps in') + mounted.app.unmount() + }) + it('tells a failure once, with its cause', async () => { // The badge says Failed; only the stage and the cause line say what failed. const getTask = vi.fn(async () => ({ diff --git a/src/components/jobs/JobKeyWait.test.ts b/src/components/jobs/JobKeyWait.test.ts new file mode 100644 index 00000000..1ca187ec --- /dev/null +++ b/src/components/jobs/JobKeyWait.test.ts @@ -0,0 +1,41 @@ +import { defineComponent, h } from 'vue' +import * as VueRuntime from 'vue' +import { describe, expect, it } from 'vitest' +import * as Wording from '@/lib/bucketEncryption' +import { compileClientComponent, content, element, mountApp, moduleDefault } from '@/test/clientRender' + +const Slotted = (tag: string) => + defineComponent({ inheritAttrs: false, setup: (_, { attrs, slots }) => () => h(tag, attrs, slots.default?.()) }) +const LinkStub = defineComponent({ + props: { to: Object }, + setup: (props, { slots }) => () => h('a', { 'data-to': JSON.stringify(props.to) }, slots.default?.()), +}) + +const wait = compileClientComponent(new URL('./JobKeyWait.vue', import.meta.url), { + vue: VueRuntime, + 'vue-router': { RouterLink: LinkStub }, + '@/components/ui/NodeLabel.vue': moduleDefault(defineComponent({ props: { nodeId: String }, setup: (props) => () => h('span', props.nodeId) })), + '@/components/ui/Notice.vue': moduleDefault(Slotted('aside')), + '@/lib/bucketEncryption': Wording, +}) + +describe('job waiting for a bucket key', () => { + it('links every locked bucket to its Encryption tab on its own node', async () => { + const { root } = await mountApp(wait, { props: { waits: [{ node_id: 'node-b', bucket: 'reef', group_id: 'g-1' }] } }) + const link = element(root, (node) => node.tag === 'a') + + expect(content(root)).toContain('neither fails nor') + expect(content(root)).toContain('reef on node-b') + expect(JSON.parse(String(link.props['data-to']))).toEqual({ + name: 'bucket-storage', + params: { bucketId: 'reef' }, + query: { tab: 'encryption', node: 'node-b', group: 'g-1' }, + }) + }) + + it('says so when the node named no bucket', async () => { + const { root } = await mountApp(wait, { props: { waits: [] } }) + + expect(content(root)).toContain('did not say which bucket') + }) +}) diff --git a/src/components/jobs/JobKeyWait.vue b/src/components/jobs/JobKeyWait.vue new file mode 100644 index 00000000..936788df --- /dev/null +++ b/src/components/jobs/JobKeyWait.vue @@ -0,0 +1,27 @@ + + + diff --git a/src/components/jobs/JobsPanel.vue b/src/components/jobs/JobsPanel.vue index d61ea213..19b576f5 100644 --- a/src/components/jobs/JobsPanel.vue +++ b/src/components/jobs/JobsPanel.vue @@ -59,7 +59,7 @@ const { // Grouped state chips over the loaded list (the tasks-panel treatment). type StateGroup = 'all' | 'active' | 'done' | 'failed' | 'cancelled' const GROUP_STATES: Record, JobState[]> = { - active: ['queued', 'claimed', 'preparing', 'ready', 'running', 'cancelling'], + active: ['queued', 'awaiting_key', 'claimed', 'preparing', 'ready', 'running', 'cancelling'], done: ['succeeded'], failed: ['failed', 'indeterminate'], cancelled: ['cancelled'], diff --git a/src/components/preview/MediaPreview.test.ts b/src/components/preview/MediaPreview.test.ts new file mode 100644 index 00000000..574ac93d --- /dev/null +++ b/src/components/preview/MediaPreview.test.ts @@ -0,0 +1,18 @@ +import * as VueRuntime from 'vue' +import { describe, expect, it, vi } from 'vitest' +import { compileClientComponent, element, mountApp } from '@/test/clientRender' + +const media = compileClientComponent(new URL('./MediaPreview.vue', import.meta.url), { vue: VueRuntime }) + +describe('media preview', () => { + it('reports a failed read of the player', async () => { + for (const mediaKind of ['video', 'audio']) { + const onFailed = vi.fn() + const { root } = await mountApp(media, { props: { url: 'https://b.test/presigned', mediaKind, onFailed } }) + + ;(element(root, (node) => node.tag === mediaKind).props.onError as () => void)() + + expect(onFailed).toHaveBeenCalledWith('https://b.test/presigned') + } + }) +}) diff --git a/src/components/preview/MediaPreview.vue b/src/components/preview/MediaPreview.vue index 77a2749d..f329106f 100644 --- a/src/components/preview/MediaPreview.vue +++ b/src/components/preview/MediaPreview.vue @@ -1,5 +1,7 @@ diff --git a/src/components/preview/PdfPreview.test.ts b/src/components/preview/PdfPreview.test.ts new file mode 100644 index 00000000..361ac4f4 --- /dev/null +++ b/src/components/preview/PdfPreview.test.ts @@ -0,0 +1,57 @@ +import { defineComponent, h } from 'vue' +import * as VueRuntime from 'vue' +import { afterEach, describe, expect, it, vi } from 'vitest' +import * as Errors from '@/composables/s3/errors' +import { compileClientComponent, flush, mountApp, moduleDefault } from '@/test/clientRender' + +const Slotted = (tag: string) => + defineComponent({ inheritAttrs: false, setup: (_, { attrs, slots }) => () => h(tag, attrs, slots.default?.()) }) + +const pdf = compileClientComponent(new URL('./PdfPreview.vue', import.meta.url), { + vue: VueRuntime, + '@lucide/vue': new Proxy({}, { get: () => Slotted('i') }), + '@/composables/s3/errors': Errors, + '@/components/ui/Button.vue': moduleDefault(Slotted('button')), + '@/components/ui/EmptyState.vue': moduleDefault(Slotted('section')), + '@/components/ui/Skeleton.vue': moduleDefault(Slotted('div')), +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('pdf preview', () => { + it('reports a locked bucket instead of a plain loading failure', async () => { + const headers = { 'x-aruna-bucket-locked': 'true' } + vi.stubGlobal('fetch', vi.fn(async () => new Response('', { status: 403, headers }))) + const onLocked = vi.fn() + + await mountApp(pdf, { props: { url: 'https://b.test/presigned', onLocked } }) + await flush() + + expect(onLocked).toHaveBeenCalledWith('https://b.test/presigned') + }) + + it('reports nothing once it is gone before its read answers', async () => { + let answer!: (response: Response) => void + vi.stubGlobal('fetch', vi.fn(() => new Promise((resolve) => (answer = resolve)))) + const onLocked = vi.fn() + + const { app } = await mountApp(pdf, { props: { url: 'https://b.test/presigned', onLocked } }) + app.unmount() + answer(new Response('', { status: 403, headers: { 'x-aruna-bucket-locked': 'true' } })) + await flush() + + expect(onLocked).not.toHaveBeenCalled() + }) + + it('keeps an ordinary refusal a loading failure', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response('', { status: 403 }))) + const onLocked = vi.fn() + + await mountApp(pdf, { props: { url: 'https://b.test/presigned', onLocked } }) + await flush() + + expect(onLocked).not.toHaveBeenCalled() + }) +}) diff --git a/src/components/preview/PdfPreview.vue b/src/components/preview/PdfPreview.vue index 7318ed25..6e05bbd6 100644 --- a/src/components/preview/PdfPreview.vue +++ b/src/components/preview/PdfPreview.vue @@ -3,12 +3,17 @@ // api/src/csp.rs serves `frame-src blob:`); the bytes are fetched over the // already-allowed connect-src path, so the iframe never touches the S3 origin. import { onBeforeUnmount, onMounted, ref } from 'vue' +import { BUCKET_LOCKED_HEADER } from '@/composables/s3/errors' import Button from '@/components/ui/Button.vue' import EmptyState from '@/components/ui/EmptyState.vue' import Skeleton from '@/components/ui/Skeleton.vue' import { ExternalLink, FileText } from '@lucide/vue' const props = defineProps<{ url: string; name?: string }>() +/** `locked` names the URL whose read a locked bucket refused. */ +const emit = defineEmits<{ (e: 'locked', url: string): void }>() +// A viewer that is gone neither reports nor keeps what its read returns. +const reading = new AbortController() const blobUrl = ref(null) const failed = ref(false) @@ -19,19 +24,27 @@ function openTab() { } onMounted(async () => { + const url = props.url try { - const response = await fetch(props.url) + const response = await fetch(url, { signal: reading.signal }) + if (reading.signal.aborted) return + if (response.status === 403 && response.headers.get(BUCKET_LOCKED_HEADER) === 'true') { + emit('locked', url) + return + } if (!response.ok) throw new Error(`HTTP ${response.status}`) const blob = await response.blob() + if (reading.signal.aborted) return blobUrl.value = URL.createObjectURL(new Blob([blob], { type: 'application/pdf' })) } catch { - failed.value = true + if (!reading.signal.aborted) failed.value = true } finally { - loading.value = false + if (!reading.signal.aborted) loading.value = false } }) onBeforeUnmount(() => { + reading.abort() if (blobUrl.value) URL.revokeObjectURL(blobUrl.value) }) diff --git a/src/components/preview/PreviewBody.test.ts b/src/components/preview/PreviewBody.test.ts new file mode 100644 index 00000000..eb977a0e --- /dev/null +++ b/src/components/preview/PreviewBody.test.ts @@ -0,0 +1,178 @@ +import { defineComponent, h, reactive, ref } from 'vue' +import * as VueRuntime from 'vue' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { button, click, compileClientComponent, content, element, flush, mountApp, moduleDefault } from '@/test/clientRender' + +const Slotted = (tag: string) => + defineComponent({ inheritAttrs: false, setup: (_, { attrs, slots }) => () => h(tag, attrs, slots.default?.()) }) + +const load = vi.fn() +const urlFor = vi.fn() +const checkAccess = vi.fn() +const downloadUrl = vi.fn() +let token = 1 +const preview = { + status: ref('idle'), + kind: ref('download'), + language: ref(undefined), + mediaKind: ref('video'), + delimiter: ref(','), + text: ref(null), + objectUrl: ref(null), + directUrl: ref(null), + errorMessage: ref(null), + downloadError: ref(null), + corsBlocked: ref(false), + sizeNote: ref(null), + referenced: ref(false), + lockCheck: ref('idle'), + lockedLink: ref(null), + sessionKey: ref('session-1'), + load, + reset: vi.fn(), + probeReferenced: vi.fn(), + recheck: vi.fn(), + markLocked: vi.fn(), + checkAccess, + urlFor, + loadToken: () => token, + isCurrent: (id: number) => id === token, +} + +const body = compileClientComponent(new URL('./PreviewBody.vue', import.meta.url), { + vue: VueRuntime, + 'vue-router': { RouterLink: Slotted('a') }, + '@lucide/vue': new Proxy({}, { get: () => Slotted('i') }), + '@/lib/chunk-recovery': { asyncChunkError: () => undefined }, + '@/components/ui/Button.vue': moduleDefault(Slotted('button')), + '@/components/ui/ErrorPanel.vue': moduleDefault(Slotted('div')), + '@/components/ui/Notice.vue': moduleDefault(Slotted('aside')), + '@/components/ui/Spinner.vue': moduleDefault(Slotted('span')), + '@/composables/useObjectPreview': { useObjectPreview: () => preview }, + '@/composables/useS3': { useS3: () => ({ downloadUrl }), s3ErrorMessage: (error: unknown) => String(error) }, + './TextPreview.vue': moduleDefault(Slotted('div')), + './HtmlPreview.vue': moduleDefault(Slotted('div')), + './MarkdownPreview.vue': moduleDefault(Slotted('div')), + './CsvPreview.vue': moduleDefault(Slotted('div')), + './ImagePreview.vue': moduleDefault(Slotted('div')), + './MediaPreview.vue': moduleDefault(Slotted('video')), + './PdfPreview.vue': moduleDefault(Slotted('object')), + './DownloadCard.vue': moduleDefault(Slotted('div')), +}) + +const shown = reactive({ active: true, bucket: 'reef', objectKey: 'a.txt', name: 'a.txt', nodeId: null as string | null | undefined }) +const Host = defineComponent(() => () => h(body, { ...shown })) +const anchor = { href: '', download: '', rel: '', click: vi.fn(), remove: vi.fn() } + +beforeEach(() => { + Object.assign(shown, { active: true, bucket: 'reef', objectKey: 'a.txt', name: 'a.txt', nodeId: null }) + preview.sessionKey.value = 'session-1' + preview.status.value = 'idle' + preview.kind.value = 'download' + preview.directUrl.value = null + preview.downloadError.value = null + preview.markLocked.mockReset() + token = 1 + load.mockReset() + urlFor.mockReset().mockReturnValue(null) + checkAccess.mockReset().mockResolvedValue(true) + downloadUrl.mockReset().mockResolvedValue('https://signed/fresh') + anchor.click.mockReset() + vi.stubGlobal('document', { createElement: () => anchor, body: { append: vi.fn() } }) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('preview body', () => { + it('reads the object again when its bucket, node or session changes', async () => { + await mountApp(Host) + + shown.bucket = 'other' + await flush() + shown.nodeId = 'node-b' + await flush() + preview.sessionKey.value = 'session-2' + await flush() + // Null is the connected node, an omitted node the active S3 session's node. + shown.nodeId = null + await flush() + shown.nodeId = undefined + await flush() + + expect(load.mock.calls.map(([target]) => [target.bucket, target.nodeId])).toEqual([ + ['reef', null], + ['other', null], + ['other', 'node-b'], + ['other', 'node-b'], + ['other', null], + ['other', undefined], + ]) + }) + + it('downloads only a URL signed for the object it shows', async () => { + const { root } = await mountApp(Host) + shown.bucket = 'other' + await flush() + + await click(button(root, 'Download')) + + expect(urlFor).toHaveBeenCalledWith(expect.objectContaining({ bucket: 'other', key: 'a.txt' })) + expect(downloadUrl.mock.calls[0].slice(0, 2)).toEqual(['other', 'a.txt']) + expect(anchor.href).toBe('https://signed/fresh') + expect(anchor.click).toHaveBeenCalledOnce() + }) + + it('drops a download whose preview changed while its URL was signed', async () => { + let sign!: (url: string) => void + downloadUrl.mockReturnValueOnce(new Promise((resolve) => (sign = resolve))) + const { root } = await mountApp(Host) + + const started = click(button(root, 'Download')) + token = 2 + sign('https://signed/old') + await started + + expect(checkAccess).not.toHaveBeenCalled() + expect(anchor.click).not.toHaveBeenCalled() + }) + + it('reports a failed download beside a locked or ready preview instead of replacing it', async () => { + downloadUrl.mockRejectedValue(new Error('signing failed')) + preview.status.value = 'locked' + const { root } = await mountApp(Host) + + await click(button(root, 'Download')) + expect(preview.status.value).toBe('locked') + expect(preview.downloadError.value).toBe('Error: signing failed') + + preview.status.value = 'ready' + await click(button(root, 'Download')) + await flush() + expect(preview.status.value).toBe('ready') + expect(content(element(root, (node) => node.tag === 'aside'))).toContain('signing failed') + }) + + it('acts on viewer reports only for the file it still shows', async () => { + preview.status.value = 'ready' + preview.kind.value = 'media' + preview.directUrl.value = 'https://signed/current' + const { root } = await mountApp(Host) + await vi.waitFor(() => element(root, (node) => node.tag === 'video')) + const player = element(root, (node) => node.tag === 'video') + + await (player.props.onFailed as (url: string) => Promise)('https://signed/old') + expect(checkAccess).not.toHaveBeenCalled() + await (player.props.onFailed as (url: string) => Promise)('https://signed/current') + expect(checkAccess).toHaveBeenCalledWith('https://signed/current', 1) + + preview.kind.value = 'pdf' + await vi.waitFor(() => element(root, (node) => node.tag === 'object')) + const pdf = element(root, (node) => node.tag === 'object') + ;(pdf.props.onLocked as (url: string) => void)('https://signed/old') + expect(preview.markLocked).not.toHaveBeenCalled() + ;(pdf.props.onLocked as (url: string) => void)('https://signed/current') + expect(preview.markLocked).toHaveBeenCalledOnce() + }) +}) diff --git a/src/components/preview/PreviewBody.vue b/src/components/preview/PreviewBody.vue index 3c769fbd..b9de633b 100644 --- a/src/components/preview/PreviewBody.vue +++ b/src/components/preview/PreviewBody.vue @@ -58,9 +58,8 @@ const preview = useObjectPreview() const isHtml = computed(() => /\.x?html?$/i.test(props.name) || (props.contentType ?? '').toLowerCase().startsWith('text/html')) -function reload() { - if (!props.objectKey) return - const target = { +function shownTarget() { + return { bucket: props.bucket, key: props.objectKey, size: props.size, @@ -68,13 +67,29 @@ function reload() { nodeId: props.nodeId, versionId: props.versionId ?? undefined, } +} + +function reload() { + if (!props.objectKey) return + const target = shownTarget() void preview.load(target) // After load(): its reset() would drop an earlier-started probe. if (props.probeReference && !props.referencedFrom) void preview.probeReferenced(target) } +// Any change of the object, its bucket or node, or the account and session reads it again. watch( - () => [props.active, props.objectKey, props.versionId] as const, + () => + [ + props.active, + props.bucket, + props.nodeId ?? null, + props.objectKey, + props.versionId ?? null, + props.size, + props.contentType, + preview.sessionKey.value, + ] as const, ([active]) => { if (active && props.objectKey) reload() else preview.reset() @@ -82,11 +97,15 @@ watch( { immediate: true }, ) +// A download belongs to the preview it was started from; a newer one drops its result. async function download() { if (!props.objectKey) return + preview.downloadError.value = null + const id = preview.loadToken() + const target = shownTarget() try { const url = - preview.directUrl.value + preview.urlFor(target) ?? (await s3.downloadUrl( props.bucket, props.objectKey, @@ -94,6 +113,9 @@ async function download() { props.versionId ?? undefined, props.name, )) + if (!preview.isCurrent(id)) return + const open = await preview.checkAccess(url, id, target) + if (!preview.isCurrent(id) || !open) return // In the document, not detached: a detached anchor is ignored by some // browsers, and the name travels in the response's Content-Disposition. const anchor = document.createElement('a') @@ -104,10 +126,19 @@ async function download() { anchor.click() anchor.remove() } catch (err) { - preview.errorMessage.value = s3ErrorMessage(err) - preview.status.value = 'error' + if (preview.isCurrent(id)) preview.downloadError.value = s3ErrorMessage(err) } } + +// A player that cannot read its URL is waiting for a key only when a probe confirms the lock. +// Reports about a URL the preview no longer shows are ignored. +async function mediaFailed(url: string) { + if (url === preview.directUrl.value) await preview.checkAccess(url, preview.loadToken()) +} + +function pdfLocked(url: string) { + if (url === preview.directUrl.value) preview.markLocked() +}