Compose validate - a preflight linter & validator for compose files
This commit is contained in:
13 files changed
+2297
-15
No files matched your search
@@ -0,0 +1,77 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { runValidateWithConfig } from '$lib/server/compose-validate';
|
||||
import { buildValidateContext } from '$lib/server/compose-validate/context';
|
||||
import { getEnvironment } from '$lib/server/db';
|
||||
import type { ValidateConfig } from '$lib/server/compose-validate/types';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
/**
|
||||
* The daemon `docker compose config` should validate against. `config` renders the
|
||||
* effective compose locally (it does NOT dial the daemon), so this only matters for
|
||||
* consistency: local/socket -> default socket; direct TCP -> that host. Hawser/edge
|
||||
* agents are remote, so we skip config for them (null) and fall back to local rules.
|
||||
*/
|
||||
async function resolveDockerHostForConfig(envIdNum: number | undefined): Promise<string | null> {
|
||||
if (!envIdNum) return null; // no env -> local socket
|
||||
const env = await getEnvironment(envIdNum).catch(() => undefined);
|
||||
if (!env) return null;
|
||||
const type = env.connectionType || 'socket';
|
||||
if (type === 'socket') return null;
|
||||
if (type === 'direct' && env.host) {
|
||||
const proto = env.protocol === 'https' ? 'https' : 'tcp';
|
||||
return `${proto}://${env.host}:${env.port || 2375}`;
|
||||
}
|
||||
return null; // hawser-standard / hawser-edge: no local TCP host, config renders locally anyway
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a compose file (Compose Validate): a preflight linter that surfaces
|
||||
* problems - port collisions, exposure, typos, undefined network/volume refs, latest
|
||||
* tags, docker.sock mounts - before `docker compose up`, with the line of each finding
|
||||
* for editor markers. Context-aware rules (cross-stack port collisions, missing
|
||||
* external networks/volumes, container-name collisions) use the target environment's
|
||||
* live containers/networks/volumes.
|
||||
*
|
||||
* @openapi
|
||||
* summary: Run the Compose Validate preflight linter on a compose file and return findings with severities and line numbers (requires the 'view' permission)
|
||||
* path: name:string! Stack name (used to exclude the stack's own containers from cross-stack collision checks)
|
||||
* query: env:integer Environment ID for the context-aware checks (from GET /api/environments)
|
||||
* body: {compose:string!, config:{disabled:[string], severity:object}}
|
||||
* body-example: {"compose":"services:\n web:\n image: nginx:latest\n ports: [\"8080:80\"]\n"}
|
||||
* resp-200: {findings:[{ruleId:string!, severity:string!, message:string!, hint:string, service:string, line:integer, fix:object, fixDescription:string}]!, counts:{error:integer!, warn:integer!, info:integer!}!}
|
||||
* resp-200-example: {"findings":[{"ruleId":"LATEST_TAG","severity":"warn","message":"\"web\" uses `:latest` (nginx:latest)","hint":"Pin a version tag for reproducible deploys and to enable newer-version detection.","service":"web","line":3}],"counts":{"error":0,"warn":1,"info":0}}
|
||||
* resp-400: compose content is required
|
||||
* resp-403: Permission denied
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ cookies, url, params, request }) => {
|
||||
const auth = await authorize(cookies);
|
||||
const envId = url.searchParams.get('env');
|
||||
const envIdNum = envId ? parseInt(envId, 10) : undefined;
|
||||
|
||||
if (auth.authEnabled && !(await auth.can('stacks', 'view', envIdNum))) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as {
|
||||
compose?: string;
|
||||
config?: ValidateConfig;
|
||||
};
|
||||
const compose = typeof body.compose === 'string' ? body.compose : '';
|
||||
if (!compose.trim()) {
|
||||
return json({ error: 'compose content is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const stackName = decodeURIComponent(params.name);
|
||||
|
||||
// Best-effort live context; degrades to an empty context if the env is unreachable
|
||||
// (context-aware rules then simply produce nothing - the linter still runs).
|
||||
const ctx = await buildValidateContext(envIdNum ?? null, stackName).catch(() => ({}));
|
||||
|
||||
// `docker compose config` gives the authoritative schema/syntax verdict; the local
|
||||
// rule catalog adds the context-aware checks config can't do. Config degrades to a
|
||||
// pure local lint if the daemon/CLI is unreachable, so this never blocks.
|
||||
const dockerHost = await resolveDockerHostForConfig(envIdNum);
|
||||
const report = await runValidateWithConfig(stackName, compose, ctx, dockerHost, body.config ?? {});
|
||||
return json(report);
|
||||
};
|
||||
@@ -0,0 +1,280 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { CircleX, TriangleAlert, Info, CheckCircle2, ArrowRight, Loader2, RefreshCw, X, ChevronDown, Wand2 } from 'lucide-svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { findingKey, type QuickFix } from '$lib/utils/compose-quick-fix';
|
||||
|
||||
export interface ValidateFinding {
|
||||
ruleId: string;
|
||||
severity: 'error' | 'warn' | 'info';
|
||||
message: string;
|
||||
hint?: string;
|
||||
service?: string;
|
||||
line?: number;
|
||||
fix?: QuickFix;
|
||||
fixDescription?: string;
|
||||
}
|
||||
export interface ValidateReport {
|
||||
findings: ValidateFinding[];
|
||||
counts: { error: number; warn: number; info: number };
|
||||
}
|
||||
|
||||
interface Props {
|
||||
report: ValidateReport | null;
|
||||
loading?: boolean;
|
||||
error?: string | null;
|
||||
activeLine?: number | null;
|
||||
onClose: () => void;
|
||||
onJumpToLine?: (line: number) => void;
|
||||
onRevalidate?: () => void;
|
||||
onApplyFix?: (finding: ValidateFinding) => void;
|
||||
}
|
||||
let { report, loading = false, error = null, activeLine = null, onClose, onJumpToLine, onRevalidate, onApplyFix }: Props = $props();
|
||||
|
||||
const SEV = {
|
||||
error: { label: 'Errors', Icon: CircleX, color: 'var(--sev-error)', bg: 'rgba(239,68,68,0.12)' },
|
||||
warn: { label: 'Warnings', Icon: TriangleAlert, color: 'var(--sev-warn)', bg: 'rgba(245,158,11,0.12)' },
|
||||
info: { label: 'Suggestions', Icon: Info, color: 'var(--sev-info)', bg: 'rgba(59,130,246,0.12)' }
|
||||
} as const;
|
||||
|
||||
const findings = $derived(report?.findings ?? []);
|
||||
const counts = $derived(report?.counts ?? { error: 0, warn: 0, info: 0 });
|
||||
const total = $derived(counts.error + counts.warn + counts.info);
|
||||
const clean = $derived(!loading && !error && report !== null && total === 0);
|
||||
const grouped = $derived(
|
||||
(['error', 'warn', 'info'] as const)
|
||||
.map((sev) => ({ sev, items: findings.filter((f) => f.severity === sev) }))
|
||||
.filter((g) => g.items.length > 0)
|
||||
);
|
||||
|
||||
// Collapsed severity groups (all expanded by default).
|
||||
let collapsed = $state<Record<'error' | 'warn' | 'info', boolean>>({ error: false, warn: false, info: false });
|
||||
function toggleGroup(sev: 'error' | 'warn' | 'info') {
|
||||
collapsed = { ...collapsed, [sev]: !collapsed[sev] };
|
||||
}
|
||||
|
||||
// Explicitly scroll the finding for a line into view (called by the parent when a
|
||||
// gutter icon is clicked - NOT on every re-validate, which would make the list jump).
|
||||
let bodyEl = $state<HTMLDivElement | null>(null);
|
||||
export function scrollToFinding(line: number) {
|
||||
const target = findings.find((f) => f.line === line);
|
||||
if (target && collapsed[target.severity]) {
|
||||
collapsed = { ...collapsed, [target.severity]: false };
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
bodyEl?.querySelector(`[data-line="${line}"]`)?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="cvp">
|
||||
<!-- Header: title + count chips inline -->
|
||||
<div class="cvp-header">
|
||||
<div class="cvp-title">Compose Validate</div>
|
||||
{#if !loading && !error && report}
|
||||
<div class="cvp-chips">
|
||||
{#each ['error', 'warn', 'info'] as const as sev}
|
||||
{@const c = counts[sev]}
|
||||
{@const SIcon = SEV[sev].Icon}
|
||||
<div class="cvp-chip" class:cvp-chip-muted={c === 0} style="--chip-color: {SEV[sev].color}; --chip-bg: {SEV[sev].bg}">
|
||||
<SIcon class="h-3 w-3" />
|
||||
<span class="cvp-chip-count">{c}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="cvp-header-actions">
|
||||
{#if onRevalidate}
|
||||
<button class="cvp-iconbtn" title="Re-check" onclick={onRevalidate} disabled={loading}>
|
||||
{#if loading}<Loader2 class="h-4 w-4 animate-spin" />{:else}<RefreshCw class="h-4 w-4" />{/if}
|
||||
</button>
|
||||
{/if}
|
||||
<button class="cvp-iconbtn" title="Close panel" onclick={onClose}><X class="h-4 w-4" /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="cvp-body" bind:this={bodyEl}>
|
||||
{#if loading}
|
||||
<div class="cvp-center"><Loader2 class="h-5 w-5 animate-spin text-muted-foreground" /><p>Validating...</p></div>
|
||||
{:else if error}
|
||||
<div class="cvp-center cvp-error-state"><CircleX class="h-5 w-5" /><p>{error}</p></div>
|
||||
{:else if clean}
|
||||
<div class="cvp-center cvp-clean">
|
||||
<div class="cvp-clean-badge"><CheckCircle2 class="h-7 w-7" /></div>
|
||||
<p class="cvp-clean-title">No problems found</p>
|
||||
<p class="cvp-clean-sub">Passes every enabled check.</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#each grouped as group (group.sev)}
|
||||
{@const GIcon = SEV[group.sev].Icon}
|
||||
<div class="cvp-group">
|
||||
<button type="button" class="cvp-group-head" style="--chip-color: {SEV[group.sev].color}" onclick={() => toggleGroup(group.sev)}>
|
||||
<ChevronDown class="cvp-chevron h-3.5 w-3.5 {collapsed[group.sev] ? '-rotate-90' : ''}" />
|
||||
<GIcon class="h-3.5 w-3.5" />
|
||||
<span>{SEV[group.sev].label}</span>
|
||||
<span class="cvp-group-count">{group.items.length}</span>
|
||||
</button>
|
||||
{#if !collapsed[group.sev]}
|
||||
{#each group.items as f (findingKey(f))}
|
||||
<div
|
||||
class="cvp-finding"
|
||||
class:cvp-finding-active={activeLine != null && f.line === activeLine}
|
||||
style="--chip-color: {SEV[f.severity].color}"
|
||||
data-line={f.line ?? undefined}
|
||||
transition:slide={{ duration: 200 }}
|
||||
>
|
||||
<div class="cvp-finding-bar"></div>
|
||||
<div class="cvp-finding-body">
|
||||
<div class="cvp-finding-top">
|
||||
<span class="cvp-rule">{f.ruleId}</span>
|
||||
{#if f.line}<span class="cvp-line">L{f.line}<ArrowRight class="h-3 w-3" /></span>{/if}
|
||||
{#if f.fix && onApplyFix}
|
||||
<button
|
||||
type="button"
|
||||
class="cvp-fix"
|
||||
title={f.fixDescription ?? 'Apply quick fix'}
|
||||
onclick={() => onApplyFix?.(f)}
|
||||
>
|
||||
<Wand2 class="h-3 w-3" />
|
||||
Fix
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="cvp-finding-jump"
|
||||
onclick={() => f.line && onJumpToLine?.(f.line)}
|
||||
disabled={!f.line}
|
||||
>
|
||||
<p class="cvp-message">{f.message}</p>
|
||||
{#if f.hint}<p class="cvp-hint">{f.hint}</p>{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.cvp {
|
||||
--sev-error: #ef4444;
|
||||
--sev-warn: #f59e0b;
|
||||
--sev-info: #3b82f6;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
.cvp-header {
|
||||
display: flex; align-items: center; gap: 0.5rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
}
|
||||
.cvp-title { font-size: 0.82rem; font-weight: 650; line-height: 1.1; white-space: nowrap; }
|
||||
.cvp-chips { display: flex; gap: 0.3rem; margin-left: 0.15rem; }
|
||||
.cvp-header-actions { display: flex; gap: 0.15rem; margin-left: auto; }
|
||||
.cvp-iconbtn {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 1.6rem; height: 1.6rem; border-radius: 0.35rem;
|
||||
color: hsl(var(--muted-foreground)); flex-shrink: 0;
|
||||
transition: background 0.12s ease, color 0.12s ease;
|
||||
}
|
||||
.cvp-iconbtn:hover { background: hsl(var(--muted)); color: hsl(var(--foreground)); }
|
||||
.cvp-iconbtn:disabled { opacity: 0.5; }
|
||||
|
||||
.cvp-chip {
|
||||
display: inline-flex; align-items: center; gap: 0.3rem;
|
||||
padding: 0.2rem 0.5rem; border-radius: 999px; font-size: 0.72rem;
|
||||
color: var(--chip-color); background: var(--chip-bg);
|
||||
border: 1px solid color-mix(in srgb, var(--chip-color) 30%, transparent);
|
||||
}
|
||||
.cvp-chip-muted { opacity: 0.4; filter: grayscale(0.6); }
|
||||
.cvp-chip-count { font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
|
||||
.cvp-body { flex: 1; overflow-y: auto; padding: 0.5rem 0.6rem 0.75rem; }
|
||||
.cvp-center {
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
gap: 0.6rem; padding: 2.5rem 0.75rem; color: hsl(var(--muted-foreground));
|
||||
text-align: center; font-size: 0.8rem;
|
||||
}
|
||||
.cvp-error-state { color: var(--sev-error); }
|
||||
.cvp-clean-badge {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 3rem; height: 3rem; border-radius: 999px;
|
||||
background: rgba(34,197,94,0.14); color: #22c55e;
|
||||
}
|
||||
.cvp-clean-title { font-size: 0.9rem; font-weight: 600; color: hsl(var(--foreground)); }
|
||||
.cvp-clean-sub { font-size: 0.76rem; }
|
||||
|
||||
.cvp-group { margin-top: 0.7rem; }
|
||||
.cvp-group:first-child { margin-top: 0.15rem; }
|
||||
.cvp-group-head {
|
||||
display: flex; align-items: center; gap: 0.35rem; width: 100%;
|
||||
font-size: 0.68rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em;
|
||||
color: var(--chip-color); margin-bottom: 0.35rem;
|
||||
padding: 0.15rem 0.2rem; border-radius: 0.3rem;
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
.cvp-group-head:hover { background: hsl(var(--muted) / 0.5); }
|
||||
:global(.cvp-chevron) { transition: transform 0.15s ease; flex-shrink: 0; opacity: 0.8; }
|
||||
.cvp-group-count {
|
||||
font-variant-numeric: tabular-nums;
|
||||
background: color-mix(in srgb, var(--chip-color) 15%, transparent);
|
||||
border-radius: 999px; padding: 0 0.35rem; font-size: 0.66rem;
|
||||
}
|
||||
|
||||
.cvp-finding {
|
||||
display: flex; align-items: stretch; width: 100%; text-align: left;
|
||||
background: hsl(var(--background));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.45rem; margin-bottom: 0.35rem; overflow: hidden;
|
||||
transition: border-color 0.12s ease, background 0.12s ease;
|
||||
}
|
||||
.cvp-finding:hover {
|
||||
border-color: color-mix(in srgb, var(--chip-color) 55%, hsl(var(--border)));
|
||||
}
|
||||
.cvp-finding-active {
|
||||
border-color: var(--chip-color) !important;
|
||||
background: color-mix(in srgb, var(--chip-color) 8%, hsl(var(--background)));
|
||||
}
|
||||
.cvp-finding-bar { width: 3px; background: var(--chip-color); flex-shrink: 0; }
|
||||
.cvp-finding-body { padding: 0.5rem 0.65rem; min-width: 0; flex: 1; display: flex; flex-direction: column; }
|
||||
.cvp-finding-jump {
|
||||
text-align: left; background: transparent; transition: transform 0.06s ease;
|
||||
}
|
||||
.cvp-finding-jump:not(:disabled):hover { cursor: pointer; }
|
||||
.cvp-finding-jump:not(:disabled):active { transform: translateY(1px); }
|
||||
.cvp-finding-jump:disabled { cursor: default; }
|
||||
.cvp-fix {
|
||||
display: inline-flex; align-items: center; gap: 0.2rem; flex-shrink: 0;
|
||||
margin-left: 0.4rem; padding: 0.12rem 0.4rem; border-radius: 0.3rem;
|
||||
font-size: 0.66rem; font-weight: 600;
|
||||
color: hsl(var(--muted-foreground));
|
||||
background: hsl(var(--muted));
|
||||
border: 1px solid hsl(var(--border));
|
||||
transition: background 0.12s ease, color 0.12s ease;
|
||||
}
|
||||
.cvp-fix:hover { background: hsl(var(--accent)); color: hsl(var(--foreground)); cursor: pointer; }
|
||||
.cvp-fix:active { transform: translateY(1px); }
|
||||
.cvp-finding-top { display: flex; align-items: center; gap: 0.4rem; margin-bottom: 0.2rem; }
|
||||
.cvp-rule {
|
||||
font-family: ui-monospace, monospace; font-size: 0.64rem; font-weight: 600;
|
||||
color: var(--chip-color);
|
||||
background: color-mix(in srgb, var(--chip-color) 12%, transparent);
|
||||
padding: 0.03rem 0.35rem; border-radius: 0.28rem;
|
||||
}
|
||||
.cvp-line {
|
||||
display: inline-flex; align-items: center; gap: 0.1rem;
|
||||
font-size: 0.66rem; color: hsl(var(--muted-foreground)); margin-left: auto;
|
||||
}
|
||||
.cvp-message { font-size: 0.78rem; color: hsl(var(--foreground)); line-height: 1.3; }
|
||||
.cvp-hint { font-size: 0.72rem; color: hsl(var(--muted-foreground)); margin-top: 0.15rem; line-height: 1.3; }
|
||||
</style>
|
||||
@@ -10,7 +10,9 @@
|
||||
import SecretProviderPicker from '$lib/components/SecretProviderPicker.svelte';
|
||||
import { SELECTOR_VARS } from '$lib/utils/bulk-selector';
|
||||
import { classifyMarker, resolvedRefVarNames } from '$lib/utils/invault-markers';
|
||||
import { Layers, Save, Play, Code, GitGraph, GitBranch, GitCommitHorizontal, Github, Loader2, AlertCircle, X, Sun, Moon, TriangleAlert, GripVertical, FolderOpen, Copy, Check, XCircle, MapPin, ArrowRight, ArrowDown, Info, Box, FolderSync, Archive } from 'lucide-svelte';
|
||||
import { applyQuickFix, findingKey } from '$lib/utils/compose-quick-fix';
|
||||
import { Layers, Save, Play, Code, GitGraph, GitBranch, GitCommitHorizontal, Github, Loader2, AlertCircle, X, Sun, Moon, TriangleAlert, GripVertical, FolderOpen, Copy, Check, XCircle, MapPin, ArrowRight, ArrowDown, Info, Box, FolderSync, Archive, ListChecks } from 'lucide-svelte';
|
||||
import ComposeValidatePanel from './ComposeValidatePanel.svelte';
|
||||
import BackupPanel from '../containers/BackupPanel.svelte';
|
||||
import { volumesForStack, type VolumeInfo } from '$lib/utils/mounts';
|
||||
import { fetchBackupExecutions } from '$lib/utils/backup';
|
||||
@@ -145,6 +147,137 @@
|
||||
let composePathCopied = $state<'ok' | 'error' | null>(null);
|
||||
let envPathCopied = $state<'ok' | 'error' | null>(null);
|
||||
let composeContentCopied = $state<'ok' | 'error' | null>(null);
|
||||
|
||||
// --- Compose Validate (side panel) ------------------------------------------
|
||||
let validatePanelOpen = $state(false);
|
||||
let validateLoading = $state(false);
|
||||
let validateError = $state<string | null>(null);
|
||||
let validateActiveLine = $state<number | null>(null);
|
||||
let validateReport = $state<import('./ComposeValidatePanel.svelte').ValidateReport | null>(null);
|
||||
// Monotonic token: only the newest validate response is allowed to write the report,
|
||||
// so a slow silent re-validate can't overwrite a newer one (fix-spam race).
|
||||
let validateSeq = 0;
|
||||
// Findings mapped to editor lint markers (only those with a line).
|
||||
const validateMarkers = $derived(
|
||||
(validateReport?.findings ?? [])
|
||||
.filter((f) => typeof f.line === 'number')
|
||||
.map((f) => ({ line: f.line!, severity: f.severity, ruleId: f.ruleId, message: f.message }))
|
||||
);
|
||||
|
||||
async function runComposeValidate(opts: { silent?: boolean } = {}) {
|
||||
if (!composeContent.trim()) return;
|
||||
// Silent re-validate (after a quick fix) keeps the current list visible so the
|
||||
// panel doesn't collapse to a spinner and lose the scroll position.
|
||||
if (!opts.silent) validateLoading = true;
|
||||
validateError = null;
|
||||
validatePanelOpen = true;
|
||||
const seq = ++validateSeq;
|
||||
try {
|
||||
const envId = $currentEnvironment?.id ?? null;
|
||||
const name = (mode === 'edit' ? stackName : newStackName) || 'stack';
|
||||
const res = await fetch(
|
||||
appendEnvParam(`/api/stacks/${encodeURIComponent(name)}/validate`, envId),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ compose: composeContent })
|
||||
}
|
||||
);
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Validation failed (${res.status})`);
|
||||
}
|
||||
const fresh = await res.json();
|
||||
// Stale response (a newer validate started meanwhile): drop it entirely.
|
||||
if (seq !== validateSeq) return;
|
||||
// On a silent re-validate, only swap the report if the finding set actually
|
||||
// changed. When a fix succeeded the optimistic list already matches the fresh
|
||||
// one, so keeping the same object avoids re-rendering (and the flash) of the
|
||||
// surviving boxes.
|
||||
if (opts.silent && validateReport && sameFindingSet(validateReport.findings, fresh.findings)) {
|
||||
// no-op: current (optimistic) report is already correct
|
||||
} else {
|
||||
validateReport = fresh;
|
||||
}
|
||||
} catch (e) {
|
||||
if (seq !== validateSeq) return; // superseded - don't clobber a newer report
|
||||
validateError = e instanceof Error ? e.message : 'Validation failed';
|
||||
validateReport = null;
|
||||
} finally {
|
||||
if (seq === validateSeq) validateLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Two finding lists are "the same" set (order-independent) by their stable keys.
|
||||
function sameFindingSet(
|
||||
a: { ruleId: string; line?: number; message: string }[],
|
||||
b: { ruleId: string; line?: number; message: string }[]
|
||||
): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
const bag = new Map<string, number>();
|
||||
for (const f of a) bag.set(findingKey(f), (bag.get(findingKey(f)) ?? 0) + 1);
|
||||
for (const f of b) {
|
||||
const k = findingKey(f);
|
||||
const n = bag.get(k);
|
||||
if (!n) return false;
|
||||
bag.set(k, n - 1);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Remove a finding from the current report immediately (optimistic), so its box
|
||||
// animates out without waiting for the round-trip.
|
||||
function dropFinding(target: { ruleId: string; line?: number; message: string }) {
|
||||
if (!validateReport) return;
|
||||
const targetKey = findingKey(target);
|
||||
const remaining = validateReport.findings.filter((f) => findingKey(f) !== targetKey);
|
||||
const counts = { error: 0, warn: 0, info: 0 };
|
||||
for (const f of remaining) counts[f.severity]++;
|
||||
validateReport = { findings: remaining, counts };
|
||||
}
|
||||
|
||||
// Closing the panel clears the findings so the editor markers disappear too
|
||||
// (validateMarkers is derived from validateReport).
|
||||
function closeValidatePanel() {
|
||||
validatePanelOpen = false;
|
||||
validateReport = null;
|
||||
validateError = null;
|
||||
validateActiveLine = null;
|
||||
}
|
||||
|
||||
// Clicking a gutter marker opens the panel, highlights that line's finding, and
|
||||
// scrolls the panel to it (the editor->panel direction).
|
||||
function openValidateAtLine(line: number) {
|
||||
if (validateReport) validatePanelOpen = true;
|
||||
validateActiveLine = line;
|
||||
validatePanelRef?.scrollToFinding?.(line);
|
||||
}
|
||||
|
||||
// Clicking a finding in the panel jumps the editor to its line (panel stays open).
|
||||
function jumpToComposeLine(line: number) {
|
||||
codeEditorRef?.scrollToLine?.(line);
|
||||
validateActiveLine = line;
|
||||
}
|
||||
|
||||
// Apply a quick fix from the panel: rewrite the compose in place, drop the fixed
|
||||
// finding's box immediately (it animates out), then re-validate silently so the list
|
||||
// stays put - no spinner, no scroll reset.
|
||||
function applyValidateFix(finding: {
|
||||
ruleId: string;
|
||||
line?: number;
|
||||
message: string;
|
||||
fix?: import('$lib/utils/compose-quick-fix').QuickFix;
|
||||
}) {
|
||||
if (!finding.fix) return;
|
||||
const next = applyQuickFix(composeContent, finding.fix);
|
||||
if (next === composeContent) return; // stale fix (text moved) - re-validate re-anchors
|
||||
composeContent = next;
|
||||
// The reactive editor sync suppresses onchange, so mark dirty ourselves.
|
||||
isDirty = true;
|
||||
validateActiveLine = null;
|
||||
dropFinding(finding); // optimistic: the box animates out now
|
||||
runComposeValidate({ silent: true }); // reconcile against the daemon without a flash
|
||||
}
|
||||
let needsFileLocation = $state(false);
|
||||
|
||||
// Container info for untracked stacks
|
||||
@@ -597,6 +730,7 @@
|
||||
|
||||
// CodeEditor reference for explicit marker updates
|
||||
let codeEditorRef: CodeEditor | null = $state(null);
|
||||
let validatePanelRef: ComposeValidatePanel | null = $state(null);
|
||||
|
||||
// ComposeGraphViewer reference for resize on panel toggle
|
||||
let graphViewerRef: ComposeGraphViewer | null = $state(null);
|
||||
@@ -822,12 +956,32 @@
|
||||
isDraggingSplit = true;
|
||||
}
|
||||
|
||||
// Validate side-panel width (px), drag-resizable, persisted.
|
||||
const STORAGE_KEY_VALIDATE_W = 'dockhand-validate-panel-width';
|
||||
let validatePanelWidth = $state(
|
||||
typeof localStorage !== 'undefined'
|
||||
? Number(localStorage.getItem(STORAGE_KEY_VALIDATE_W)) || 288
|
||||
: 288
|
||||
);
|
||||
let isDraggingValidate = $state(false);
|
||||
let editorRowRef = $state<HTMLDivElement | null>(null);
|
||||
function startValidateDrag(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
isDraggingValidate = true;
|
||||
}
|
||||
|
||||
function handleMouseMove(e: MouseEvent) {
|
||||
if (isDraggingSplit && containerRef) {
|
||||
const rect = containerRef.getBoundingClientRect();
|
||||
const newRatio = ((e.clientX - rect.left) / rect.width) * 100;
|
||||
splitRatio = Math.max(30, Math.min(80, newRatio));
|
||||
}
|
||||
if (isDraggingValidate && editorRowRef) {
|
||||
const rect = editorRowRef.getBoundingClientRect();
|
||||
// panel is on the right: width = distance from cursor to the row's right edge.
|
||||
const w = rect.right - e.clientX;
|
||||
validatePanelWidth = Math.max(220, Math.min(560, w));
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseUp() {
|
||||
@@ -836,6 +990,10 @@
|
||||
// Save split ratio
|
||||
localStorage.setItem(STORAGE_KEY_SPLIT, splitRatio.toString());
|
||||
}
|
||||
if (isDraggingValidate) {
|
||||
isDraggingValidate = false;
|
||||
localStorage.setItem(STORAGE_KEY_VALIDATE_W, String(validatePanelWidth));
|
||||
}
|
||||
}
|
||||
|
||||
// Populate the backup picker's volume/bind list from this stack's containers'
|
||||
@@ -1791,8 +1949,23 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div class="h-full flex flex-col">
|
||||
<!-- Copy button row -->
|
||||
<div class="flex justify-end mb-1">
|
||||
<!-- Copy + Validate button row -->
|
||||
<div class="flex justify-end items-center gap-1 mb-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 px-2 text-xs text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-200"
|
||||
onclick={runComposeValidate}
|
||||
disabled={!composeContent}
|
||||
title="Check this compose for problems before deploy"
|
||||
>
|
||||
{#if validateLoading}
|
||||
<Loader2 class="w-3 h-3 animate-spin" />
|
||||
{:else}
|
||||
<ListChecks class="w-3 h-3" />
|
||||
{/if}
|
||||
Validate
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -1817,16 +1990,47 @@
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
<CodeEditor
|
||||
bind:this={codeEditorRef}
|
||||
value={composeContent}
|
||||
language="yaml"
|
||||
{readonly}
|
||||
theme={editorTheme}
|
||||
onchange={readonly ? undefined : handleComposeChange}
|
||||
variableMarkers={variableMarkers}
|
||||
class="flex-1 rounded-md overflow-hidden border border-zinc-200 dark:border-zinc-700"
|
||||
/>
|
||||
<div bind:this={editorRowRef} class="flex-1 min-h-0 flex">
|
||||
<CodeEditor
|
||||
bind:this={codeEditorRef}
|
||||
value={composeContent}
|
||||
language="yaml"
|
||||
{readonly}
|
||||
theme={editorTheme}
|
||||
onchange={readonly ? undefined : handleComposeChange}
|
||||
variableMarkers={variableMarkers}
|
||||
lintMarkers={validateMarkers}
|
||||
onLintClick={openValidateAtLine}
|
||||
class="flex-1 min-w-0 rounded-md overflow-hidden border border-zinc-200 dark:border-zinc-700"
|
||||
/>
|
||||
{#if validatePanelOpen}
|
||||
<!-- Resize handle -->
|
||||
<div
|
||||
class="w-1 mx-1 flex-shrink-0 rounded bg-zinc-200 dark:bg-zinc-700 hover:bg-blue-400 dark:hover:bg-blue-500 cursor-col-resize transition-colors flex items-center justify-center group {isDraggingValidate ? 'bg-blue-500 dark:bg-blue-400' : ''}"
|
||||
onmousedown={startValidateDrag}
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
tabindex="0"
|
||||
>
|
||||
<div class="w-4 h-8 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity {isDraggingValidate ? 'opacity-100' : ''}">
|
||||
<GripVertical class="w-3 h-3 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="shrink-0 min-h-0" style="width: {validatePanelWidth}px">
|
||||
<ComposeValidatePanel
|
||||
bind:this={validatePanelRef}
|
||||
report={validateReport}
|
||||
loading={validateLoading}
|
||||
error={validateError}
|
||||
activeLine={validateActiveLine}
|
||||
onClose={closeValidatePanel}
|
||||
onJumpToLine={jumpToComposeLine}
|
||||
onRevalidate={runComposeValidate}
|
||||
onApplyFix={applyValidateFix}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user