mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-08 00:03:07 +00:00
* fix(backend): authorize single-job read endpoints by job/flow visibility Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(jobs): share read links + cached access checks for run visibility - Cache the job read-access RLS probe (size-bounded LRU keyed by the caller's authz-relevant identity + job id; no TTL since job-side inputs are immutable). - Inherit visibility along the full parent_job chain so any flow you can see lets you read its (deeply nested) steps. - Share read links: GET /jobs/job_view_token/{id} mints a stateless HMAC(workspace_key, job_id) token (only if the caller can read the job); the token grants an authenticated member read of that job and its flow subtree via a ?view_token query param or X-View-Token header. Run page gains a Share button and honors a ?view_token link. - Denied-but-existing reads now return 403 with guidance to request a share link (vs 404 for non-existent), and the run page renders that case with instructions. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(jobs): address PR review — scope-tag check on mint, constant-time view-token verify - P1 (Codex): get_job_view_token now enforces the caller's if_jobs:filter_tags scope before minting, so a tag-scoped token can't mint a transferable link for a job outside its tags. Adds a scoped-token regression test (allowed + denied). - Constant-time view-token verification (HmacSha256::verify_slice) instead of comparing hex strings (Claude/Pi nit). - get_completed_job_result: an authed reader passing an invalid suspended-secret triple now falls through to the normal visibility gate instead of erroring out (Claude nit); unauthenticated callers still rejected. - Length-prefix the read-access cache key fields so no input values can collide. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(api): add job_view_token to openapi spec; use generated client in run page Addresses Codex review nit: the new GET /jobs/job_view_token/{id} endpoint was missing from openapi.yaml (the source the frontend client is generated from). Adds the path + operationId getJobViewToken, and switches the run page's Share button from a raw fetch to JobService.getJobViewToken. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(frontend): carry view_token on share-link downloads Addresses Codex review: download actions bypass the request interceptor that adds X-View-Token (downloadViaClient uses raw fetch; cookie-mode downloads use plain hrefs), so a share-link viewer got 403 downloading logs/results/args. Append the view_token query param to the job download paths (result/logs/args/flow-all-logs) via a new appendViewToken() helper, covering both client-fetch and href modes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(jobs): enforce tag scope in require_job_read_access (view-token use side) Addresses Codex P1: the view_token use-side bypassed if_jobs:filter_tags on handlers that don't tag-filter their data query (result_by_id, get_flow_job_debug_info, get_otel_traces) — a tag-scoped token could use someone else's valid share token to read out-of-scope job data. Move the tag-scope check into require_job_read_access (runs before any created_by/view_token/RLS grant), so it applies uniformly to every gated handler; removes the now-redundant explicit check in get_job_view_token. Adds a use-side regression test (scoped token + valid out-of-scope view_token denied on otel/result_by_id; in-scope still allowed). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(frontend): include workspace in share read link Addresses Codex P1: the copied share URL omitted the workspace. The token is signed with the run's workspace key and the logged layout only switches $workspaceStore when the URL carries workspace=, so a recipient whose persisted active workspace differs would open the link against the wrong workspace and the token would fail validation. Pin workspace= alongside view_token in the link. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(jobs): authorize get_result_maybe get_started branch for queued jobs Addresses Codex P1: get_completed_job_result_maybe only gated when a completed row existed; with ?get_started=true a non-reader reached the fallback branch and got started:true for a running private job. Now fetches created_by and authorizes (created_by/view_token/RLS, or anonymous for unauth) before disclosing running-state; a non-existent job still returns started:false (leaks nothing). Adds a regression test with a queued (no completed row) private job. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
662 lines
18 KiB
Svelte
662 lines
18 KiB
Svelte
<script lang="ts" module>
|
|
import { untrack } from 'svelte'
|
|
const s3LogPrefixes = [
|
|
'[windmill] Previous logs have been saved to object storage at logs/',
|
|
'[windmill] Previous logs have been saved to disk at logs/',
|
|
'[windmill] No object storage set in instance settings. Previous logs have been saved to disk at logs/'
|
|
]
|
|
|
|
// Only search within first N characters to avoid expensive full-content scans
|
|
const S3_LOG_SEARCH_LIMIT = 2000
|
|
</script>
|
|
|
|
<script lang="ts">
|
|
import { ClipboardCopy, Download, Expand, Loader2, Timer, Cpu } from 'lucide-svelte'
|
|
import { Button, Drawer, DrawerContent } from './common'
|
|
import { copyToClipboard } from '$lib/utils'
|
|
import { base } from '$lib/base'
|
|
import { withExternalDomain } from '$lib/externalDomain'
|
|
import { downloadViaClient, shouldDownloadViaClient } from '$lib/utils/downloadFile'
|
|
import { appendViewToken } from '$lib/viewToken'
|
|
import { workspaceStore } from '$lib/stores'
|
|
import { AnsiUp } from 'ansi_up'
|
|
import NoWorkerWithTagWarning from './runs/NoWorkerWithTagWarning.svelte'
|
|
import { JobService } from '$lib/gen'
|
|
import { WM_LOGS_SKIPPED } from '$lib/consts'
|
|
import Tooltip from './Tooltip.svelte'
|
|
import { twMerge } from 'tailwind-merge'
|
|
import QueuePosition from './QueuePosition.svelte'
|
|
|
|
interface Props {
|
|
content: string | undefined
|
|
isLoading: boolean
|
|
duration?: number | undefined
|
|
mem?: number | undefined
|
|
wrapperClass?: string
|
|
jobId?: string | undefined
|
|
tag: string | undefined
|
|
small?: boolean
|
|
drawerOpen?: boolean
|
|
noMaxH?: boolean
|
|
noAutoScroll?: boolean
|
|
download?: boolean
|
|
customEmptyMessage?: string
|
|
tagLabel?: string
|
|
noPadding?: boolean
|
|
navigationId?: string
|
|
/** Called once after we resolve a WM_LOGS_SKIPPED sentinel by fetching the
|
|
* full job. Use this to write the real logs back into the source of truth
|
|
* (e.g. flowStateStore) so subsequent mounts don't refetch. */
|
|
onLogsResolved?: (logs: string) => void
|
|
}
|
|
|
|
let {
|
|
content,
|
|
isLoading,
|
|
duration = undefined,
|
|
mem = undefined,
|
|
wrapperClass = '',
|
|
jobId = undefined,
|
|
tag,
|
|
small = false,
|
|
drawerOpen = $bindable(false),
|
|
noMaxH = false,
|
|
noAutoScroll = false,
|
|
download = true,
|
|
customEmptyMessage = 'No logs are available yet',
|
|
tagLabel = undefined,
|
|
noPadding = false,
|
|
navigationId = undefined,
|
|
onLogsResolved
|
|
}: Props = $props()
|
|
|
|
// @ts-ignore
|
|
const ansi_up = $state(new AnsiUp())
|
|
|
|
ansi_up.use_classes = true
|
|
|
|
let scroll = $state(true)
|
|
let preEl: HTMLElement | null = $state(null)
|
|
|
|
// let downloadStartUrl: string | undefined = undefined
|
|
|
|
let LOG_INC = 10000
|
|
let LOG_LIMIT = $state(LOG_INC)
|
|
|
|
let lastJobId = $state(untrack(() => jobId))
|
|
|
|
let loadedFromObjectStore = $state('')
|
|
|
|
// `content` is the WM_LOGS_SKIPPED sentinel when the job was fetched with
|
|
// no_logs=true. If an older in-memory value accidentally has real bytes
|
|
// concatenated after the sentinel, treat that as skipped too and refetch.
|
|
let isLogsSkipped = $derived((content ?? '').startsWith(WM_LOGS_SKIPPED))
|
|
let resolvedSkippedLogs: string | undefined = $state(undefined)
|
|
let fetchedSkippedJobId: string | undefined = $state(undefined)
|
|
let effectiveContent = $derived(isLogsSkipped ? resolvedSkippedLogs : content)
|
|
let resolvingSkippedLogs = $derived(isLogsSkipped && !!jobId && resolvedSkippedLogs === undefined)
|
|
|
|
$effect(() => {
|
|
if (!isLogsSkipped || !jobId || fetchedSkippedJobId === jobId) {
|
|
return
|
|
}
|
|
const id = jobId
|
|
fetchedSkippedJobId = id
|
|
untrack(() => {
|
|
JobService.getJob({ workspace: $workspaceStore ?? '', id })
|
|
.then((j) => {
|
|
if (fetchedSkippedJobId === id) {
|
|
const logs = (j as { logs?: string })['logs'] ?? ''
|
|
resolvedSkippedLogs = logs
|
|
onLogsResolved?.(logs)
|
|
}
|
|
})
|
|
.catch((e) => console.error('Failed to resolve skipped logs', e))
|
|
})
|
|
})
|
|
|
|
function findPrefixInfo(
|
|
truncateContent: string
|
|
): { prefixIndex: number; position: number } | undefined {
|
|
// Quick check - [windmill] is rare, so bail early in the common case
|
|
// This is much faster than doing 3 long string indexOf searches
|
|
const windmillPos = truncateContent.indexOf('[windmill]')
|
|
if (windmillPos === -1 || windmillPos >= S3_LOG_SEARCH_LIMIT) {
|
|
return undefined
|
|
}
|
|
|
|
// Found [windmill] marker, now determine which specific prefix matches
|
|
for (let i = 0; i < s3LogPrefixes.length; i++) {
|
|
const prefix = s3LogPrefixes[i]
|
|
if (
|
|
truncateContent.length >= windmillPos + prefix.length &&
|
|
truncateContent.startsWith(prefix, windmillPos)
|
|
) {
|
|
return { prefixIndex: i, position: windmillPos }
|
|
}
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
function findStartUrl(
|
|
truncateContent: string,
|
|
prefixInfo: { prefixIndex: number; position: number } | undefined
|
|
) {
|
|
if (!prefixInfo) {
|
|
return undefined
|
|
}
|
|
|
|
const { prefixIndex, position } = prefixInfo
|
|
const prefix = s3LogPrefixes[prefixIndex]
|
|
const startOfPath = position + prefix.length
|
|
const endOfLine = truncateContent.indexOf('\n', startOfPath)
|
|
|
|
return truncateContent.substring(startOfPath, endOfLine === -1 ? undefined : endOfLine)
|
|
}
|
|
|
|
function splitAtWindmillLine(
|
|
content: string,
|
|
prefixInfo: { prefixIndex: number; position: number }
|
|
): { before: string; after: string } {
|
|
const { prefixIndex, position } = prefixInfo
|
|
const prefix = s3LogPrefixes[prefixIndex]
|
|
|
|
// Find line boundaries
|
|
const lineStart = position > 0 && content[position - 1] === '\n' ? position - 1 : position
|
|
const pathStart = position + prefix.length
|
|
const lineEnd = content.indexOf('\n', pathStart)
|
|
|
|
if (lineEnd === -1) {
|
|
// [windmill] line is at the end
|
|
return { before: content.substring(0, lineStart), after: '' }
|
|
}
|
|
|
|
return {
|
|
before: content.substring(0, lineStart),
|
|
after: content.substring(lineEnd)
|
|
}
|
|
}
|
|
|
|
function tooltipText(prefixInfo: { prefixIndex: number; position: number } | undefined) {
|
|
if (prefixInfo == undefined) {
|
|
return 'No path/file detected to download from'
|
|
} else if (prefixInfo.prefixIndex == 0) {
|
|
return 'Download the previous logs from the instance configured object store'
|
|
} else if (prefixInfo.prefixIndex == 1) {
|
|
return 'Attempt to download the logs from disk. Assume there is a shared disk between the workers and the server at /tmp/windmill/logs. Upgrade to EE to use an object store such as S3 instead of a shared volume.'
|
|
} else if (prefixInfo.prefixIndex == 2) {
|
|
return 'Attempt to download the logs from disk. Assume there is a shared disk between the workers and the server at /tmp/windmill/logs. Since you are on EE, you can alternatively use an object store such as S3 configured in the instance settings instead of a shared volume..'
|
|
}
|
|
}
|
|
|
|
function truncateContent(
|
|
jobContent: string | undefined,
|
|
loadedFromObjectStore: string,
|
|
limit: number
|
|
) {
|
|
let content = loadedFromObjectStore + (jobContent ?? '')
|
|
if (content.length > limit) {
|
|
return content.substring(content.length - limit)
|
|
}
|
|
return content
|
|
}
|
|
|
|
export function scrollToBottom() {
|
|
scroll && setTimeout(() => preEl?.scroll({ top: preEl?.scrollHeight, behavior: 'smooth' }), 100)
|
|
}
|
|
|
|
let logViewer: Drawer | undefined = $state()
|
|
|
|
async function getStoreLogs() {
|
|
if (downloadStartUrl) {
|
|
scroll = false
|
|
let res = (await JobService.getLogFileFromStore({
|
|
workspace: $workspaceStore ?? '',
|
|
path: downloadStartUrl
|
|
})) as string
|
|
LOG_LIMIT += Math.min(LOG_INC, res.length)
|
|
loadedFromObjectStore = res + loadedFromObjectStore
|
|
let newC = truncateContent(effectiveContent, loadedFromObjectStore, LOG_LIMIT)
|
|
LOG_LIMIT -= newC.indexOf('\n') + 1
|
|
} else {
|
|
console.error('No file detected to download from')
|
|
}
|
|
}
|
|
|
|
function showMoreTruncate(len: number) {
|
|
scroll = false
|
|
LOG_LIMIT += LOG_INC
|
|
let newC = truncateContent(effectiveContent, loadedFromObjectStore, LOG_LIMIT)
|
|
let newlineIndex = newC.indexOf('\n') + 1
|
|
if (newlineIndex < LOG_INC / 2) {
|
|
LOG_LIMIT -= newlineIndex
|
|
}
|
|
}
|
|
$effect.pre(() => {
|
|
if (jobId !== lastJobId) {
|
|
lastJobId = jobId
|
|
loadedFromObjectStore = ''
|
|
LOG_LIMIT = LOG_INC
|
|
scroll = true
|
|
resolvedSkippedLogs = undefined
|
|
fetchedSkippedJobId = undefined
|
|
}
|
|
})
|
|
let logsApiPath = $derived(appendViewToken(`/w/${$workspaceStore}/jobs_u/get_logs/${jobId}`))
|
|
let downloadHref = $derived(withExternalDomain(`${base}/api${logsApiPath}`))
|
|
let downloadName = $derived(`windmill_logs_${jobId}.txt`)
|
|
let truncatedContent = $derived(
|
|
truncateContent(effectiveContent, loadedFromObjectStore, LOG_LIMIT)
|
|
)
|
|
let prefixInfo = $derived(findPrefixInfo(truncatedContent))
|
|
let downloadStartUrl = $derived(findStartUrl(truncatedContent, prefixInfo))
|
|
$effect.pre(() => {
|
|
truncatedContent && scrollToBottom()
|
|
})
|
|
|
|
// When [windmill] line is NOT at start, split into before/after to render button inline
|
|
let splitHtml = $derived.by(() => {
|
|
if (prefixInfo == undefined || prefixInfo.position === 0) {
|
|
return undefined
|
|
}
|
|
const { before, after } = splitAtWindmillLine(truncatedContent, prefixInfo)
|
|
return {
|
|
before: ansi_up.ansi_to_html(before),
|
|
after: ansi_up.ansi_to_html(after)
|
|
}
|
|
})
|
|
|
|
// Only compute html when splitHtml won't be used (avoids wasteful ansi_to_html call)
|
|
let html = $derived.by(() => {
|
|
if (splitHtml) {
|
|
// splitHtml is active - skip expensive computation
|
|
return ''
|
|
}
|
|
if (prefixInfo == undefined) {
|
|
// No [windmill] line - return full content
|
|
return ansi_up.ansi_to_html(truncatedContent)
|
|
}
|
|
// [windmill] at start - strip the line and return the rest
|
|
const { after } = splitAtWindmillLine(truncatedContent, prefixInfo)
|
|
return ansi_up.ansi_to_html(after)
|
|
})
|
|
</script>
|
|
|
|
<Drawer bind:this={logViewer} bind:open={drawerOpen} size="900px">
|
|
<DrawerContent title="Expanded Logs" on:close={logViewer.closeDrawer}>
|
|
{#snippet actions()}
|
|
{#if jobId && download}
|
|
{#if shouldDownloadViaClient()}
|
|
<Button
|
|
on:click={() => downloadViaClient(logsApiPath, downloadName)}
|
|
color="light"
|
|
size="xs"
|
|
startIcon={{
|
|
icon: Download
|
|
}}
|
|
>
|
|
Download
|
|
</Button>
|
|
{:else}
|
|
<Button
|
|
href={downloadHref}
|
|
download={downloadName}
|
|
color="light"
|
|
size="xs"
|
|
startIcon={{
|
|
icon: Download
|
|
}}
|
|
>
|
|
Download
|
|
</Button>
|
|
{/if}
|
|
{/if}
|
|
|
|
<Button
|
|
on:click={() => copyToClipboard(effectiveContent)}
|
|
color="light"
|
|
size="xs"
|
|
startIcon={{
|
|
icon: ClipboardCopy
|
|
}}
|
|
>
|
|
Copy to clipboard
|
|
</Button>
|
|
{/snippet}
|
|
<div>
|
|
<pre
|
|
class="bg-surface-secondary text-primary text-xs w-full p-2 whitespace-pre-wrap border rounded-md"
|
|
>{#if resolvingSkippedLogs}<Loader2
|
|
class="animate-spin"
|
|
size={14}
|
|
/>{:else if effectiveContent}{@const len =
|
|
(effectiveContent?.length ?? 0) +
|
|
(loadedFromObjectStore?.length ?? 0)}{#if splitHtml}{@html splitHtml.before}<button
|
|
onclick={getStoreLogs}
|
|
>Show more... <Tooltip>{tooltipText(prefixInfo)}</Tooltip></button
|
|
>{@html splitHtml.after}{:else if downloadStartUrl}<button onclick={getStoreLogs}
|
|
>Show more... <Tooltip>{tooltipText(prefixInfo)}</Tooltip></button
|
|
><br
|
|
/>{@html html}{:else if len > LOG_LIMIT}(truncated to the last {LOG_LIMIT} characters)...<br
|
|
/><button onclick={() => showMoreTruncate(len)}>Show more..</button><br
|
|
/>{@html html}{:else}{@html html}{/if}{:else if isLoading}Waiting for job to start...{:else}No logs are available yet{/if}</pre
|
|
>
|
|
</div>
|
|
</DrawerContent>
|
|
</Drawer>
|
|
|
|
<div class="w-full h-full {wrapperClass}">
|
|
<div class="w-full h-full relative">
|
|
<div
|
|
class="w-full h-full bg-surface-secondary flex flex-col {noMaxH ? '' : 'max-h-screen'}"
|
|
data-nav-id={navigationId}
|
|
>
|
|
<div
|
|
class="flex gap-2 ml-2 {small ? 'py-1' : 'py-2'} border-b overflow-x-auto overflow-y-hidden"
|
|
>
|
|
{#if isLoading}
|
|
<div class="flex gap-2 items-center">
|
|
<Loader2 class="animate-spin" />
|
|
{#if tag}
|
|
<div class="flex flex-row items-center gap-1">
|
|
<div class="text-secondary text-2xs">{tagLabel ?? 'tag'}: {tag}</div>
|
|
<NoWorkerWithTagWarning {tagLabel} {tag} />
|
|
</div>
|
|
{/if}
|
|
{#if jobId}
|
|
<QueuePosition {jobId} />
|
|
{/if}
|
|
</div>
|
|
{:else if duration}
|
|
<span
|
|
class={twMerge(
|
|
'flex items-center gap-1 text-secondary dark:text-gray-400',
|
|
small ? '!text-2xs' : '!text-xs'
|
|
)}
|
|
title="Duration"
|
|
>
|
|
<Timer size={small ? 10 : 12} />
|
|
{duration}ms
|
|
</span>
|
|
{/if}
|
|
{#if mem}
|
|
<span
|
|
class={twMerge(
|
|
'flex items-center gap-1 text-secondary dark:text-gray-400',
|
|
small ? '!text-2xs' : '!text-xs'
|
|
)}
|
|
title="Memory peak"
|
|
>
|
|
<Cpu size={small ? 10 : 12} />
|
|
{(mem / 1024).toPrecision(4)}MB
|
|
</span>
|
|
{/if}
|
|
<div class="flex gap-2 justify-end flex-1">
|
|
{#if jobId && download}
|
|
<div class="flex items-center">
|
|
{#if shouldDownloadViaClient()}
|
|
<button
|
|
class="text-primary pb-0.5"
|
|
onclick={() => downloadViaClient(logsApiPath, downloadName)}
|
|
><Download size="14" />
|
|
</button>
|
|
{:else}
|
|
<a
|
|
class="text-primary pb-0.5"
|
|
target="_blank"
|
|
href={downloadHref}
|
|
download={downloadName}
|
|
><Download size="14" />
|
|
</a>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
<button onclick={logViewer.openDrawer}><Expand size="12" /></button>
|
|
{#if !noAutoScroll}
|
|
<label
|
|
class="pr-2 text-2xs flex gap-2 font-normal text-primary items-center whitespace-nowrap"
|
|
>
|
|
auto-scroll
|
|
<input class="windmillapp" type="checkbox" bind:checked={scroll} />
|
|
</label>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
<pre
|
|
bind:this={preEl}
|
|
class={twMerge(
|
|
'whitespace-pre break-words w-full flex-1 overflow-auto',
|
|
small ? '!text-2xs' : '!text-xs',
|
|
noPadding ? '' : 'p-2'
|
|
)}
|
|
>{#if resolvingSkippedLogs}<span class="flex p-2"
|
|
><Loader2 class="animate-spin" size={14} /></span
|
|
>{:else if effectiveContent}{@const len =
|
|
(effectiveContent?.length ?? 0) +
|
|
(loadedFromObjectStore?.length ?? 0)}{#if splitHtml}<span>{@html splitHtml.before}</span
|
|
><button onclick={getStoreLogs}
|
|
>Show more... <Tooltip>{tooltipText(prefixInfo)}</Tooltip></button
|
|
><span>{@html splitHtml.after}</span>{:else if downloadStartUrl}<button
|
|
onclick={getStoreLogs}
|
|
>Show more... <Tooltip>{tooltipText(prefixInfo)}</Tooltip></button
|
|
><br /><span>{@html html}</span>{:else if len > LOG_LIMIT}<button
|
|
onclick={() => showMoreTruncate(len)}>Show more..</button
|
|
> ({LOG_LIMIT}/{len} chars)<br /><span>{@html html}</span>{:else}<span
|
|
>{@html html}</span
|
|
>{/if}{:else if !isLoading}<span>{customEmptyMessage}</span>{/if}</pre
|
|
>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<style global>
|
|
/* Foreground colors */
|
|
.ansi-black-fg {
|
|
color: rgb(0, 0, 0);
|
|
}
|
|
.ansi-red-fg {
|
|
color: rgb(187, 0, 0);
|
|
}
|
|
.ansi-green-fg {
|
|
color: rgb(0, 187, 0);
|
|
}
|
|
.ansi-yellow-fg {
|
|
color: rgb(187, 187, 0);
|
|
}
|
|
.ansi-blue-fg {
|
|
color: rgb(0, 0, 187);
|
|
}
|
|
.ansi-magenta-fg {
|
|
color: rgb(187, 0, 187);
|
|
}
|
|
.ansi-cyan-fg {
|
|
color: rgb(0, 187, 187);
|
|
}
|
|
.ansi-white-fg {
|
|
color: rgb(255, 255, 255);
|
|
}
|
|
|
|
.ansi-bright-black-fg {
|
|
color: rgb(85, 85, 85);
|
|
}
|
|
.ansi-bright-red-fg {
|
|
color: rgb(255, 85, 85);
|
|
}
|
|
.ansi-bright-green-fg {
|
|
color: rgb(0, 255, 0);
|
|
}
|
|
.ansi-bright-yellow-fg {
|
|
color: rgb(255, 255, 85);
|
|
}
|
|
.ansi-bright-blue-fg {
|
|
color: rgb(85, 85, 255);
|
|
}
|
|
.ansi-bright-magenta-fg {
|
|
color: rgb(255, 85, 255);
|
|
}
|
|
.ansi-bright-cyan-fg {
|
|
color: rgb(85, 255, 255);
|
|
}
|
|
.ansi-bright-white-fg {
|
|
color: rgb(255, 255, 255);
|
|
}
|
|
|
|
/* Background colors */
|
|
.ansi-black-bg {
|
|
background-color: rgb(0, 0, 0);
|
|
}
|
|
.ansi-red-bg {
|
|
background-color: rgb(187, 0, 0);
|
|
}
|
|
.ansi-green-bg {
|
|
background-color: rgb(0, 187, 0);
|
|
}
|
|
.ansi-yellow-bg {
|
|
background-color: rgb(187, 187, 0);
|
|
}
|
|
.ansi-blue-bg {
|
|
background-color: rgb(0, 0, 187);
|
|
}
|
|
.ansi-magenta-bg {
|
|
background-color: rgb(187, 0, 187);
|
|
}
|
|
.ansi-cyan-bg {
|
|
background-color: rgb(0, 187, 187);
|
|
}
|
|
.ansi-white-bg {
|
|
background-color: rgb(255, 255, 255);
|
|
}
|
|
|
|
.ansi-bright-black-bg {
|
|
background-color: rgb(85, 85, 85);
|
|
}
|
|
.ansi-bright-red-bg {
|
|
background-color: rgb(255, 85, 85);
|
|
}
|
|
.ansi-bright-green-bg {
|
|
background-color: rgb(0, 255, 0);
|
|
}
|
|
.ansi-bright-yellow-bg {
|
|
background-color: rgb(255, 255, 85);
|
|
}
|
|
.ansi-bright-blue-bg {
|
|
background-color: rgb(85, 85, 255);
|
|
}
|
|
.ansi-bright-magenta-bg {
|
|
background-color: rgb(255, 85, 255);
|
|
}
|
|
.ansi-bright-cyan-bg {
|
|
background-color: rgb(85, 255, 255);
|
|
}
|
|
.ansi-bright-white-bg {
|
|
background-color: rgb(255, 255, 255);
|
|
}
|
|
|
|
/* Foreground colors for dark mode (Nord theme) */
|
|
.dark .ansi-black-fg {
|
|
color: rgb(46, 52, 64);
|
|
}
|
|
.dark .ansi-red-fg {
|
|
color: rgb(191, 97, 106);
|
|
}
|
|
.dark .ansi-green-fg {
|
|
color: rgb(163, 190, 140);
|
|
}
|
|
.dark .ansi-yellow-fg {
|
|
color: rgb(235, 203, 139);
|
|
}
|
|
.dark .ansi-blue-fg {
|
|
color: rgb(94, 129, 172);
|
|
}
|
|
.dark .ansi-magenta-fg {
|
|
color: rgb(180, 142, 173);
|
|
}
|
|
.dark .ansi-cyan-fg {
|
|
color: rgb(136, 192, 208);
|
|
}
|
|
.dark .ansi-white-fg {
|
|
color: rgb(216, 222, 233);
|
|
}
|
|
|
|
.dark .ansi-bright-black-fg {
|
|
color: rgb(67, 76, 94);
|
|
}
|
|
.dark .ansi-bright-red-fg {
|
|
color: rgb(191, 97, 106);
|
|
}
|
|
.dark .ansi-bright-green-fg {
|
|
color: rgb(163, 190, 140);
|
|
}
|
|
.dark .ansi-bright-yellow-fg {
|
|
color: rgb(235, 203, 139);
|
|
}
|
|
.dark .ansi-bright-blue-fg {
|
|
color: rgb(94, 129, 172);
|
|
}
|
|
.dark .ansi-bright-magenta-fg {
|
|
color: rgb(180, 142, 173);
|
|
}
|
|
.dark .ansi-bright-cyan-fg {
|
|
color: rgb(136, 192, 208);
|
|
}
|
|
.dark .ansi-bright-white-fg {
|
|
color: rgb(229, 233, 240);
|
|
}
|
|
|
|
/* Background colors for dark mode (Nord theme) */
|
|
.dark .ansi-black-bg {
|
|
background-color: rgb(46, 52, 64);
|
|
}
|
|
.dark .ansi-red-bg {
|
|
background-color: rgb(191, 97, 106);
|
|
}
|
|
.dark .ansi-green-bg {
|
|
background-color: rgb(163, 190, 140);
|
|
}
|
|
.dark .ansi-yellow-bg {
|
|
background-color: rgb(235, 203, 139);
|
|
}
|
|
.dark .ansi-blue-bg {
|
|
background-color: rgb(94, 129, 172);
|
|
}
|
|
.dark .ansi-magenta-bg {
|
|
background-color: rgb(180, 142, 173);
|
|
}
|
|
.dark .ansi-cyan-bg {
|
|
background-color: rgb(136, 192, 208);
|
|
}
|
|
.dark .ansi-white-bg {
|
|
background-color: rgb(216, 222, 233);
|
|
}
|
|
|
|
.dark .ansi-bright-black-bg {
|
|
background-color: rgb(67, 76, 94);
|
|
}
|
|
.dark .ansi-bright-red-bg {
|
|
background-color: rgb(191, 97, 106);
|
|
}
|
|
.dark .ansi-bright-green-bg {
|
|
background-color: rgb(163, 190, 140);
|
|
}
|
|
.dark .ansi-bright-yellow-bg {
|
|
background-color: rgb(235, 203, 139);
|
|
}
|
|
.dark .ansi-bright-blue-bg {
|
|
background-color: rgb(94, 129, 172);
|
|
}
|
|
.dark .ansi-bright-magenta-bg {
|
|
background-color: rgb(180, 142, 173);
|
|
}
|
|
.dark .ansi-bright-cyan-bg {
|
|
background-color: rgb(136, 192, 208);
|
|
}
|
|
.dark .ansi-bright-white-bg {
|
|
background-color: rgb(229, 233, 240);
|
|
}
|
|
|
|
[data-nav-id]:focus-visible {
|
|
outline: none;
|
|
outline-offset: 0;
|
|
}
|
|
</style>
|