feat(gitops): config-as-code via .tinyforge.yml for repo-backed workloads
A dockerfile or static workload can opt in to reading its deploy config from a
.tinyforge.yml in its own repo. Tinyforge fetches the file, shows field-level
drift vs the live config, and an admin applies it with an explicit Sync. The
repo becomes the source of truth for the declared fields. Manual-sync only;
no auto-apply on deploy, no multi-workload reconcile, no create/delete in v1.
Scope is deliberately source-aware and source_config-resident: dockerfile
declares port/healthcheck/deploy_strategy, static declares deploy_strategy.
The file never carries repo coords or secrets (those stay in the encrypted
DB), which keeps credentials out of the repo.
Backend:
- internal/gitops: Spec/ParseSpec (KnownFields rejects unknown keys), a
source-aware ApplyPlan/BuildPlan, MergeAndValidate (omitted-field-preserving
deep merge + validate-the-merged-result-then-commit — never a partial
config), declared-only Drift with normalization, and Fetch with
ok/no_file/fetch_failed/invalid statuses and token-redacted messages.
- staticsite: DownloadFile added to GitProvider + Gitea/GitHub/GitLab impls,
reusing each provider's SSRF-safe client; 64 KiB cap; ErrFileNotFound.
- store: 4 additive gitops_* columns + setters (disjoint from UpdateWorkload
so the edit-form save and a sync never clobber each other).
- api: GET /workloads/{id}/gitops (status + raw + live drift + managed_fields),
PUT /gitops (admin, enable/path, traversal-safe), POST /gitops/sync (admin,
per-workload locked read->merge->validate->write, audited to event_log).
Frontend:
- GitOpsPanel.svelte: status pill, a purpose-built field-level drift view,
.tinyforge.yml preview, enable ToggleSwitch, Sync via ConfirmDialog; all five
statuses handled, admin affordances gated on the real viewer role.
- GitOps-managed badge (list + detail hero) and a read-only edit-form banner.
- api.ts fetchers + types; i18n apps.detail.gitops.* (en + ru parity).
Built phase-by-phase with an adversarial plan review (caught 5 design flaws
pre-implementation) and an independent review per phase (go / security / ts /
final) — all APPROVE, 0 CRITICAL/HIGH. docs/gitops.md documents the schema and
what's intentionally out of v1. Plan: plans/gitops/.
This commit is contained in:
@@ -976,6 +976,52 @@ export function rollbackWorkload(
|
||||
return post(`/api/workloads/${id}/rollback`, { deploy_id: deployId });
|
||||
}
|
||||
|
||||
// ── GitOps (config-as-code) ─────────────────────────────────────────
|
||||
// One rich payload per workload folds the file preview, parsed status, and
|
||||
// field-level drift into a single GET so the panel makes one call. The shape
|
||||
// mirrors the Go `gitOpsStatusResponse` (snake_case is preserved end-to-end,
|
||||
// matching the rest of this file). Drift entries list only the declared
|
||||
// fields that DIFFER from live; `managed_fields` lists every key the file
|
||||
// declares (the read-only gate keys on these).
|
||||
export interface GitOpsDriftEntry {
|
||||
field: string;
|
||||
repo_value: string;
|
||||
live_value: string;
|
||||
}
|
||||
|
||||
export type GitOpsStatusKind = 'disabled' | 'ok' | 'no_file' | 'fetch_failed' | 'invalid';
|
||||
|
||||
export interface GitOpsStatus {
|
||||
eligible: boolean;
|
||||
enabled: boolean;
|
||||
path: string;
|
||||
status: GitOpsStatusKind;
|
||||
raw: string;
|
||||
message: string;
|
||||
commit_sha: string;
|
||||
last_sync_at: string;
|
||||
drift: GitOpsDriftEntry[];
|
||||
drift_count: number;
|
||||
managed_fields: string[];
|
||||
}
|
||||
|
||||
export function fetchWorkloadGitOps(id: string, signal?: AbortSignal): Promise<GitOpsStatus> {
|
||||
return get<GitOpsStatus>(`/api/workloads/${id}/gitops`, signal);
|
||||
}
|
||||
|
||||
export function setWorkloadGitOps(
|
||||
id: string,
|
||||
body: { enabled: boolean; path: string }
|
||||
): Promise<{ enabled: boolean; path: string }> {
|
||||
return put<{ enabled: boolean; path: string }>(`/api/workloads/${id}/gitops`, body);
|
||||
}
|
||||
|
||||
export function syncWorkloadGitOps(
|
||||
id: string
|
||||
): Promise<{ status: string; commit_sha: string; applied_fields: string[]; triggered_by: string }> {
|
||||
return post(`/api/workloads/${id}/gitops/sync`);
|
||||
}
|
||||
|
||||
// ── Per-workload metrics history ────────────────────────────────────
|
||||
// CPU% and memory (bytes) summed across the workload's containers, one
|
||||
// point per sampled timestamp. Empty when stats collection is off / Docker
|
||||
|
||||
@@ -0,0 +1,818 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* GitOpsPanel
|
||||
*
|
||||
* Config-as-code for dockerfile/static workloads. The repo's
|
||||
* `.tinyforge.yml` declares a small overlay (port / healthcheck /
|
||||
* deploy_strategy); this panel shows whether the live source_config
|
||||
* matches that file (drift), previews the file, and applies it on demand
|
||||
* via "Sync now" (validate-then-commit on the server).
|
||||
*
|
||||
* Self-contained: it fetches its own GET /gitops on mount and on
|
||||
* workloadId change, and owns the enable/disable toggle. On a successful
|
||||
* sync it both re-fetches its own status AND calls `onSynced` so the parent
|
||||
* page reloads the (now-changed) workload row.
|
||||
*
|
||||
* The `.panel` / `.reg` card chrome is declared locally — Svelte scopes the
|
||||
* detail page's panel styles to that route, so a child component must carry
|
||||
* its own copy to render the forge card frame.
|
||||
*/
|
||||
import * as api from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import { toasts } from '$lib/stores/toast';
|
||||
import { fmt } from '$lib/format/datetime';
|
||||
import ToggleSwitch from './ToggleSwitch.svelte';
|
||||
import ConfirmDialog from './ConfirmDialog.svelte';
|
||||
import { IconRefresh, IconCheck, IconCopy } from './icons';
|
||||
|
||||
interface Props {
|
||||
workloadId: string;
|
||||
sourceKind: string;
|
||||
isAdmin?: boolean;
|
||||
/** Called after a successful sync so the parent reloads the workload. */
|
||||
onSynced?: () => void;
|
||||
}
|
||||
// Default false: the admin affordances (enable toggle, Sync) stay hidden
|
||||
// unless the parent explicitly proves the viewer is an admin. The server
|
||||
// also gates PUT/POST with AdminOnly, so this is defense-in-depth.
|
||||
let { workloadId, sourceKind, isAdmin = false, onSynced }: Props = $props();
|
||||
|
||||
let gitops = $state<api.GitOpsStatus | null>(null);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
let togglePending = $state(false);
|
||||
let syncing = $state(false);
|
||||
let confirmSync = $state(false);
|
||||
let copied = $state(false);
|
||||
|
||||
// Eligibility is decided client-side from the source kind so the panel can
|
||||
// render-nothing instantly. (The server also reports `eligible`; the two
|
||||
// always agree.) dockerfile + static are the git-backed sources.
|
||||
const ELIGIBLE_KINDS = ['dockerfile', 'static'];
|
||||
const eligibleByKind = $derived(ELIGIBLE_KINDS.includes(sourceKind));
|
||||
|
||||
async function load(signal?: AbortSignal): Promise<void> {
|
||||
try {
|
||||
gitops = await api.fetchWorkloadGitOps(workloadId, signal);
|
||||
error = '';
|
||||
} catch (e) {
|
||||
if (e instanceof DOMException && e.name === 'AbortError') return;
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Reload whenever workloadId changes — the parent reuses this instance
|
||||
// across /apps/A → /apps/B navigation.
|
||||
$effect(() => {
|
||||
void workloadId;
|
||||
loading = true;
|
||||
const controller = new AbortController();
|
||||
load(controller.signal);
|
||||
return () => controller.abort();
|
||||
});
|
||||
|
||||
async function onToggle(next: boolean): Promise<void> {
|
||||
if (togglePending || !gitops) return;
|
||||
togglePending = true;
|
||||
const path = gitops.path || '.tinyforge.yml';
|
||||
try {
|
||||
await api.setWorkloadGitOps(workloadId, { enabled: next, path });
|
||||
toasts.success(
|
||||
next ? $t('apps.detail.gitops.enabledToast') : $t('apps.detail.gitops.disabledToast')
|
||||
);
|
||||
await load();
|
||||
onSynced?.();
|
||||
} catch (e) {
|
||||
toasts.error(e instanceof Error ? e.message : $t('apps.detail.gitops.toggleFailed'));
|
||||
// Reload so the switch reflects the persisted (unchanged) value.
|
||||
await load();
|
||||
} finally {
|
||||
togglePending = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function doSync(): Promise<void> {
|
||||
if (syncing) return;
|
||||
syncing = true;
|
||||
try {
|
||||
const res = await api.syncWorkloadGitOps(workloadId);
|
||||
toasts.success(
|
||||
$t('apps.detail.gitops.syncedToast', {
|
||||
count: String(res.applied_fields.length),
|
||||
sha: shortSha(res.commit_sha)
|
||||
})
|
||||
);
|
||||
await load();
|
||||
onSynced?.();
|
||||
} catch (e) {
|
||||
toasts.error(e instanceof Error ? e.message : $t('apps.detail.gitops.syncFailed'));
|
||||
} finally {
|
||||
syncing = false;
|
||||
confirmSync = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyRaw(): Promise<void> {
|
||||
if (!gitops?.raw) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(gitops.raw);
|
||||
copied = true;
|
||||
setTimeout(() => (copied = false), 1500);
|
||||
} catch {
|
||||
// Clipboard unavailable (insecure context) — silently no-op; the
|
||||
// preview is visible and selectable regardless.
|
||||
}
|
||||
}
|
||||
|
||||
function shortSha(sha: string): string {
|
||||
if (!sha) return '—';
|
||||
return /^[0-9a-f]{8,}$/i.test(sha) ? sha.slice(0, 10) : sha;
|
||||
}
|
||||
|
||||
// Pill descriptor: status tone + label drive the header pill. "ok" splits
|
||||
// into in-sync (0 drift) vs N-changes so the single most important signal —
|
||||
// "does live match the repo?" — reads at a glance.
|
||||
type PillTone = 'sync' | 'drift' | 'muted' | 'warn' | 'danger';
|
||||
const pill = $derived.by((): { tone: PillTone; label: string } => {
|
||||
if (!gitops || !gitops.enabled) {
|
||||
return { tone: 'muted', label: $t('apps.detail.gitops.pillDisabled') };
|
||||
}
|
||||
switch (gitops.status) {
|
||||
case 'ok':
|
||||
return gitops.drift_count === 0
|
||||
? { tone: 'sync', label: $t('apps.detail.gitops.pillSynced') }
|
||||
: {
|
||||
tone: 'drift',
|
||||
label:
|
||||
gitops.drift_count === 1
|
||||
? $t('apps.detail.gitops.pillChangesOne')
|
||||
: $t('apps.detail.gitops.pillChangesMany', {
|
||||
count: String(gitops.drift_count)
|
||||
})
|
||||
};
|
||||
case 'no_file':
|
||||
return { tone: 'warn', label: $t('apps.detail.gitops.pillNoFile') };
|
||||
case 'fetch_failed':
|
||||
return { tone: 'danger', label: $t('apps.detail.gitops.pillFetchFailed') };
|
||||
case 'invalid':
|
||||
return { tone: 'danger', label: $t('apps.detail.gitops.pillInvalid') };
|
||||
default:
|
||||
return { tone: 'muted', label: $t('apps.detail.gitops.pillDisabled') };
|
||||
}
|
||||
});
|
||||
|
||||
const isOK = $derived(gitops?.status === 'ok');
|
||||
const inSync = $derived(isOK && (gitops?.drift_count ?? 0) === 0);
|
||||
const hasDrift = $derived(isOK && (gitops?.drift_count ?? 0) > 0);
|
||||
const lastSync = $derived(gitops?.last_sync_at ?? '');
|
||||
|
||||
// Human label for a managed source_config key.
|
||||
function fieldLabel(key: string): string {
|
||||
const k = `apps.detail.gitops.field.${key}`;
|
||||
const label = $t(k);
|
||||
// $t returns the key verbatim when missing — fall back to the raw key.
|
||||
return label === k ? key : label;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if eligibleByKind}
|
||||
<section class="panel gp-panel" aria-labelledby="gp-heading">
|
||||
<span class="reg reg-tl" aria-hidden="true"></span>
|
||||
<span class="reg reg-tr" aria-hidden="true"></span>
|
||||
<span class="reg reg-bl" aria-hidden="true"></span>
|
||||
<span class="reg reg-br" aria-hidden="true"></span>
|
||||
|
||||
<header class="panel-head">
|
||||
<div class="gp-titlewrap">
|
||||
<h2 class="panel-title" id="gp-heading">
|
||||
{$t('apps.detail.gitops.title')}<span class="title-accent">.</span>
|
||||
</h2>
|
||||
<span class="panel-sub">{$t('apps.detail.gitops.sub')}</span>
|
||||
</div>
|
||||
|
||||
<span class="gp-pill gp-pill-{pill.tone}">
|
||||
{#if pill.tone === 'sync'}
|
||||
<IconCheck size={11} />
|
||||
{:else}
|
||||
<span class="gp-pill-dot" aria-hidden="true"></span>
|
||||
{/if}
|
||||
{pill.label}
|
||||
</span>
|
||||
|
||||
{#if isAdmin}
|
||||
<div class="gp-toggle" title={$t('apps.detail.gitops.toggleHint')}>
|
||||
<span class="gp-toggle-lbl">{$t('apps.detail.gitops.toggleLabel')}</span>
|
||||
<ToggleSwitch
|
||||
checked={gitops?.enabled ?? false}
|
||||
disabled={togglePending || loading}
|
||||
ariaLabel={$t('apps.detail.gitops.toggleAria')}
|
||||
onchange={onToggle}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#if error}
|
||||
<div class="alert inline-alert" role="alert">
|
||||
<span class="alert-tag">ERR</span><span>{error}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if loading && !gitops}
|
||||
<p class="gp-hint">{$t('apps.detail.gitops.loading')}</p>
|
||||
{:else if gitops}
|
||||
{#if !gitops.enabled}
|
||||
<!-- Disabled: calm one-liner + path the file is expected at. -->
|
||||
<div class="gp-empty">
|
||||
<p class="gp-empty-lead">{$t('apps.detail.gitops.disabledLead')}</p>
|
||||
<p class="gp-empty-sub">
|
||||
{$t('apps.detail.gitops.disabledSub')}
|
||||
<code class="gp-path">{gitops.path || '.tinyforge.yml'}</code>
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Meta row: path · short sha · last sync. -->
|
||||
<div class="gp-meta">
|
||||
<span class="gp-meta-item">
|
||||
<span class="gp-meta-k">{$t('apps.detail.gitops.metaPath')}</span>
|
||||
<code class="gp-path">{gitops.path || '.tinyforge.yml'}</code>
|
||||
</span>
|
||||
{#if gitops.commit_sha}
|
||||
<span class="gp-meta-sep" aria-hidden="true">·</span>
|
||||
<span class="gp-meta-item">
|
||||
<span class="gp-meta-k">{$t('apps.detail.gitops.metaCommit')}</span>
|
||||
<code class="gp-sha" title={gitops.commit_sha}>{shortSha(gitops.commit_sha)}</code>
|
||||
</span>
|
||||
{/if}
|
||||
<span class="gp-meta-sep" aria-hidden="true">·</span>
|
||||
<span class="gp-meta-item">
|
||||
<span class="gp-meta-k">{$t('apps.detail.gitops.metaLastSync')}</span>
|
||||
{#if lastSync}
|
||||
<span class="gp-meta-v" title={$fmt.dateTime(lastSync)}>{$fmt.relative(lastSync)}</span>
|
||||
{:else}
|
||||
<span class="gp-meta-v gp-muted">{$t('apps.detail.gitops.metaNeverSynced')}</span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- ── THE DRIFT VIEW ─────────────────────────────────
|
||||
Field-level repo-vs-live diff. Purpose-built: one row per
|
||||
declared field, repo value on the left, live value on the
|
||||
right, with a connective glyph that turns amber when the
|
||||
two differ. In-sync collapses to a single confident gitops. -->
|
||||
{#if isOK}
|
||||
{#if inSync}
|
||||
<div class="gp-insync" role="status">
|
||||
<span class="gp-insync-icon" aria-hidden="true"><IconCheck size={16} /></span>
|
||||
<div class="gp-insync-text">
|
||||
<strong>{$t('apps.detail.gitops.inSyncTitle')}</strong>
|
||||
<span>{$t('apps.detail.gitops.inSyncSub')}</span>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="gp-drift" aria-label={$t('apps.detail.gitops.driftAria')}>
|
||||
<div class="gp-drift-head" aria-hidden="true">
|
||||
<span class="gp-col-field">{$t('apps.detail.gitops.driftColField')}</span>
|
||||
<span class="gp-col gp-col-repo">{$t('apps.detail.gitops.driftColRepo')}</span>
|
||||
<span class="gp-col-arrow"></span>
|
||||
<span class="gp-col gp-col-live">{$t('apps.detail.gitops.driftColLive')}</span>
|
||||
</div>
|
||||
{#each gitops.drift as d (d.field)}
|
||||
<div class="gp-drift-row">
|
||||
<span class="gp-field">{fieldLabel(d.field)}</span>
|
||||
<span class="gp-val gp-val-repo" title={d.repo_value}>{d.repo_value || '—'}</span>
|
||||
<span class="gp-arrow" aria-hidden="true">→</span>
|
||||
<span class="gp-val gp-val-live" title={d.live_value}>{d.live_value || '—'}</span>
|
||||
</div>
|
||||
{/each}
|
||||
<p class="gp-drift-foot">{$t('apps.detail.gitops.driftFoot')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if gitops.status === 'no_file'}
|
||||
<div class="gp-status-note gp-note-warn">
|
||||
<p class="gp-note-lead">{$t('apps.detail.gitops.noFileLead')}</p>
|
||||
<p class="gp-note-sub">
|
||||
{$t('apps.detail.gitops.noFileSub')}
|
||||
<code class="gp-path">{gitops.path || '.tinyforge.yml'}</code>
|
||||
</p>
|
||||
</div>
|
||||
{:else if gitops.status === 'fetch_failed'}
|
||||
<div class="gp-status-note gp-note-danger">
|
||||
<p class="gp-note-lead">{$t('apps.detail.gitops.fetchFailedLead')}</p>
|
||||
{#if gitops.message}<p class="gp-note-sub">{gitops.message}</p>{/if}
|
||||
</div>
|
||||
{:else if gitops.status === 'invalid'}
|
||||
<div class="gp-status-note gp-note-danger">
|
||||
<p class="gp-note-lead">{$t('apps.detail.gitops.invalidLead')}</p>
|
||||
{#if gitops.message}<p class="gp-note-sub gp-mono">{gitops.message}</p>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Rendered .tinyforge.yml preview (when present) ──── -->
|
||||
{#if gitops.raw}
|
||||
<div class="gp-editor">
|
||||
<div class="gp-editor-head">
|
||||
<span class="gp-dot"></span><span class="gp-dot"></span><span class="gp-dot"></span>
|
||||
<span class="gp-editor-title">{gitops.path || '.tinyforge.yml'}</span>
|
||||
<span class="gp-spacer"></span>
|
||||
<button
|
||||
type="button"
|
||||
class="gp-editor-chip"
|
||||
onclick={copyRaw}
|
||||
title={$t('apps.detail.gitops.copyAria')}
|
||||
>
|
||||
{#if copied}
|
||||
<IconCheck size={12} />{$t('apps.detail.gitops.copied')}
|
||||
{:else}
|
||||
<IconCopy size={12} />{$t('apps.detail.gitops.copy')}
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
<pre class="gp-code" aria-label={gitops.path || '.tinyforge.yml'}>{gitops.raw}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Sync action (admin) ─────────────────────────────── -->
|
||||
{#if isAdmin && (isOK || gitops.status === 'no_file')}
|
||||
<div class="gp-actions">
|
||||
<p class="gp-actions-hint">
|
||||
{hasDrift
|
||||
? $t('apps.detail.gitops.syncHintDrift')
|
||||
: $t('apps.detail.gitops.syncHintClean')}
|
||||
</p>
|
||||
<button
|
||||
class="forge-btn"
|
||||
onclick={() => (confirmSync = true)}
|
||||
disabled={syncing || !isOK}
|
||||
>
|
||||
<IconRefresh size={13} />
|
||||
<span>{syncing ? $t('apps.detail.gitops.syncing') : $t('apps.detail.gitops.syncNow')}</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if confirmSync}
|
||||
<ConfirmDialog
|
||||
open={true}
|
||||
title={$t('apps.detail.gitops.confirmTitle')}
|
||||
message={$t('apps.detail.gitops.confirmMessage')}
|
||||
confirmLabel={$t('apps.detail.gitops.syncNow')}
|
||||
confirmVariant="primary"
|
||||
onconfirm={doSync}
|
||||
oncancel={() => (confirmSync = false)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* ── Card chrome (page-scoped on the detail route; carried locally so
|
||||
this child renders the forge frame on its own). ───────────────── */
|
||||
.gp-panel {
|
||||
--accent: var(--forge-accent);
|
||||
--accent-soft: var(--forge-accent-soft);
|
||||
position: relative;
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-2xl);
|
||||
padding: 1.25rem 1.5rem 1.4rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.reg {
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-color: var(--color-brand-500);
|
||||
border-style: solid;
|
||||
border-width: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.reg-tl {
|
||||
top: -1px;
|
||||
left: -1px;
|
||||
border-top-width: 2px;
|
||||
border-left-width: 2px;
|
||||
border-top-left-radius: var(--radius-2xl);
|
||||
}
|
||||
.reg-tr {
|
||||
top: -1px;
|
||||
right: -1px;
|
||||
border-top-width: 2px;
|
||||
border-right-width: 2px;
|
||||
border-top-right-radius: var(--radius-2xl);
|
||||
}
|
||||
.reg-bl {
|
||||
bottom: -1px;
|
||||
left: -1px;
|
||||
border-bottom-width: 2px;
|
||||
border-left-width: 2px;
|
||||
border-bottom-left-radius: var(--radius-2xl);
|
||||
}
|
||||
.reg-br {
|
||||
bottom: -1px;
|
||||
right: -1px;
|
||||
border-bottom-width: 2px;
|
||||
border-right-width: 2px;
|
||||
border-bottom-right-radius: var(--radius-2xl);
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.gp-titlewrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.panel-title {
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: 0.98rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.005em;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
.title-accent {
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
.panel-sub {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* ── Status pill ─────────────────────────────────────────────── */
|
||||
.gp-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.32rem;
|
||||
margin-left: auto;
|
||||
padding: 0.22rem 0.6rem;
|
||||
border-radius: var(--radius-full);
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.66rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
border: 1px solid transparent;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.gp-pill-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
.gp-pill-sync {
|
||||
color: var(--color-success-dark);
|
||||
background: color-mix(in srgb, var(--color-success) 12%, transparent);
|
||||
border-color: color-mix(in srgb, var(--color-success) 35%, transparent);
|
||||
}
|
||||
.gp-pill-drift {
|
||||
color: var(--color-brand-700);
|
||||
background: var(--forge-accent-soft);
|
||||
border-color: color-mix(in srgb, var(--color-brand-500) 38%, transparent);
|
||||
}
|
||||
.gp-pill-warn {
|
||||
color: var(--color-warning-dark);
|
||||
background: color-mix(in srgb, var(--color-warning) 12%, transparent);
|
||||
border-color: color-mix(in srgb, var(--color-warning) 32%, transparent);
|
||||
}
|
||||
.gp-pill-danger {
|
||||
color: var(--color-danger);
|
||||
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
|
||||
border-color: color-mix(in srgb, var(--color-danger) 32%, transparent);
|
||||
}
|
||||
.gp-pill-muted {
|
||||
color: var(--text-tertiary);
|
||||
background: var(--surface-card-hover);
|
||||
border-color: var(--border-primary);
|
||||
}
|
||||
|
||||
/* ── Enable toggle ───────────────────────────────────────────── */
|
||||
.gp-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.gp-toggle-lbl {
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.gp-hint {
|
||||
font-size: 0.74rem;
|
||||
color: var(--text-tertiary);
|
||||
margin: 0.2rem 0;
|
||||
}
|
||||
|
||||
/* ── Disabled / empty ────────────────────────────────────────── */
|
||||
.gp-empty {
|
||||
padding: 0.4rem 0 0.1rem;
|
||||
}
|
||||
.gp-empty-lead {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 0.25rem;
|
||||
}
|
||||
.gp-empty-sub {
|
||||
font-size: 0.74rem;
|
||||
color: var(--text-tertiary);
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ── Meta row ────────────────────────────────────────────────── */
|
||||
.gp-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.73rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.gp-meta-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.gp-meta-k {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.66rem;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
font-family: var(--forge-mono);
|
||||
}
|
||||
.gp-meta-sep {
|
||||
color: var(--text-tertiary-soft);
|
||||
}
|
||||
.gp-muted,
|
||||
.gp-meta-v.gp-muted {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.gp-path,
|
||||
.gp-sha {
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.7rem;
|
||||
padding: 0.08rem 0.38rem;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-card-hover);
|
||||
border: 1px solid var(--border-primary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ── In-sync confident gitops ─────────────────────────────────── */
|
||||
.gp-insync {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
padding: 0.85rem 1rem;
|
||||
border-radius: var(--radius-lg);
|
||||
background: color-mix(in srgb, var(--color-success) 7%, var(--surface-card));
|
||||
border: 1px solid color-mix(in srgb, var(--color-success) 28%, transparent);
|
||||
}
|
||||
.gp-insync-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
background: var(--color-success);
|
||||
}
|
||||
.gp-insync-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
.gp-insync-text strong {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.gp-insync-text span {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ── THE DRIFT VIEW ──────────────────────────────────────────── */
|
||||
.gp-drift {
|
||||
border: 1px solid color-mix(in srgb, var(--color-brand-500) 28%, var(--border-primary));
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
background: var(--surface-card);
|
||||
}
|
||||
.gp-drift-head {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(7rem, 0.9fr) 1fr 1.6rem 1fr;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.45rem 0.85rem;
|
||||
background: var(--forge-accent-soft);
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--color-brand-500) 22%, transparent);
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.6rem;
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.gp-col-repo {
|
||||
color: var(--color-brand-700);
|
||||
}
|
||||
.gp-col-live {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.gp-drift-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(7rem, 0.9fr) 1fr 1.6rem 1fr;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.6rem 0.85rem;
|
||||
border-bottom: 1px solid var(--border-secondary);
|
||||
}
|
||||
.gp-drift-row:last-of-type {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.gp-field {
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.74rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.gp-val {
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.74rem;
|
||||
padding: 0.22rem 0.5rem;
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* Repo = the desired/incoming value: ember-tinted, the "source of truth". */
|
||||
.gp-val-repo {
|
||||
color: var(--color-brand-700);
|
||||
background: var(--forge-accent-soft);
|
||||
border: 1px solid color-mix(in srgb, var(--color-brand-500) 30%, transparent);
|
||||
font-weight: 600;
|
||||
}
|
||||
/* Live = the current value being replaced: muted, struck feel via dashed. */
|
||||
.gp-val-live {
|
||||
color: var(--text-tertiary);
|
||||
background: var(--surface-card-hover);
|
||||
border: 1px dashed var(--border-input);
|
||||
}
|
||||
.gp-arrow {
|
||||
justify-self: center;
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.gp-drift-foot {
|
||||
margin: 0;
|
||||
padding: 0.5rem 0.85rem;
|
||||
font-size: 0.68rem;
|
||||
color: var(--text-tertiary);
|
||||
background: var(--surface-card-hover);
|
||||
border-top: 1px solid var(--border-secondary);
|
||||
}
|
||||
|
||||
/* ── Status notes (no_file / fetch_failed / invalid) ─────────── */
|
||||
.gp-status-note {
|
||||
padding: 0.7rem 0.9rem;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border-primary);
|
||||
}
|
||||
.gp-note-warn {
|
||||
background: color-mix(in srgb, var(--color-warning) 7%, var(--surface-card));
|
||||
border-color: color-mix(in srgb, var(--color-warning) 26%, transparent);
|
||||
}
|
||||
.gp-note-danger {
|
||||
background: color-mix(in srgb, var(--color-danger) 6%, var(--surface-card));
|
||||
border-color: color-mix(in srgb, var(--color-danger) 26%, transparent);
|
||||
}
|
||||
.gp-note-lead {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.gp-note-sub {
|
||||
margin: 0.3rem 0 0;
|
||||
font-size: 0.73rem;
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.gp-mono {
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.7rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* ── Rendered .tinyforge.yml preview ─────────────────────────── */
|
||||
.gp-editor {
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
background: var(--surface-card);
|
||||
}
|
||||
.gp-editor-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.45rem 0.7rem;
|
||||
background: var(--surface-card-hover);
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
}
|
||||
.gp-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--border-input);
|
||||
}
|
||||
.gp-editor-title {
|
||||
margin-left: 0.3rem;
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.68rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.gp-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.gp-editor-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.18rem 0.5rem;
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-card);
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
transition: color 120ms ease, border-color 120ms ease;
|
||||
}
|
||||
.gp-editor-chip:hover {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.gp-code {
|
||||
margin: 0;
|
||||
padding: 0.85rem 1rem;
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.76rem;
|
||||
line-height: 1.55;
|
||||
color: var(--text-primary);
|
||||
white-space: pre;
|
||||
overflow-x: auto;
|
||||
tab-size: 2;
|
||||
max-height: 22rem;
|
||||
}
|
||||
|
||||
/* ── Sync action ─────────────────────────────────────────────── */
|
||||
.gp-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.gp-actions-hint {
|
||||
margin: 0;
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-tertiary);
|
||||
flex: 1;
|
||||
min-width: 12rem;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.gp-editor-chip {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1407,7 +1407,9 @@
|
||||
"colTrigger": "Trigger",
|
||||
"colCreated": "Created",
|
||||
"colActions": "Actions",
|
||||
"rowOpen": "Open"
|
||||
"rowOpen": "Open",
|
||||
"gitopsBadge": "GitOps",
|
||||
"gitopsBadgeTitle": "Deploy config for this app is managed from its repo."
|
||||
},
|
||||
"new": {
|
||||
"pageTitle": "New App · Tinyforge",
|
||||
@@ -1731,6 +1733,62 @@
|
||||
"cpuSeries": "CPU",
|
||||
"memorySeries": "Memory"
|
||||
},
|
||||
"gitops": {
|
||||
"title": "GitOps",
|
||||
"sub": "Declare this app's deploy config in your repo and sync it on demand.",
|
||||
"loading": "Loading GitOps status…",
|
||||
"disabledLead": "GitOps is off for this app.",
|
||||
"disabledSub": "Enable it to manage the deploy config from",
|
||||
"badge": "GitOps",
|
||||
"badgeTitle": "Deploy config for this app is managed from its repo.",
|
||||
"toggleLabel": "Enabled",
|
||||
"toggleAria": "Enable GitOps for this app",
|
||||
"toggleHint": "Read the deploy config from a file in this app's repo.",
|
||||
"enabledToast": "GitOps enabled.",
|
||||
"disabledToast": "GitOps disabled.",
|
||||
"toggleFailed": "Could not update GitOps settings.",
|
||||
"pillSynced": "Synced",
|
||||
"pillChangesOne": "1 change",
|
||||
"pillChangesMany": "{count} changes",
|
||||
"pillNoFile": "No file",
|
||||
"pillFetchFailed": "Fetch failed",
|
||||
"pillInvalid": "Invalid",
|
||||
"pillDisabled": "Disabled",
|
||||
"metaPath": "Path",
|
||||
"metaCommit": "Commit",
|
||||
"metaLastSync": "Last sync",
|
||||
"metaNeverSynced": "Never synced",
|
||||
"inSyncTitle": "Live config matches the repo",
|
||||
"inSyncSub": "Every declared field is already applied. Nothing to sync.",
|
||||
"driftAria": "Fields that differ between the repo file and the live config",
|
||||
"driftColField": "Field",
|
||||
"driftColRepo": "Repo",
|
||||
"driftColLive": "Live",
|
||||
"driftFoot": "Syncing applies the repo values, replacing the live ones above.",
|
||||
"noFileLead": "No config file on the branch",
|
||||
"noFileSub": "GitOps is on, but nothing was found at",
|
||||
"fetchFailedLead": "Couldn't read the config from the repo",
|
||||
"invalidLead": "The repo config couldn't be parsed",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied",
|
||||
"copyAria": "Copy the config file to the clipboard",
|
||||
"syncHintDrift": "Sync to apply the repo config to this app's live config.",
|
||||
"syncHintClean": "Live config already matches the repo.",
|
||||
"syncNow": "Sync now",
|
||||
"syncing": "Syncing…",
|
||||
"syncedToast": "Synced {count} field(s) from {sha}.",
|
||||
"syncFailed": "Sync failed.",
|
||||
"confirmTitle": "Apply the repo config?",
|
||||
"confirmMessage": "This applies the repo config to this app's live config. The current values for the declared fields are replaced.",
|
||||
"gateTitle": "Managed by .tinyforge.yml",
|
||||
"gateBody": "Edit the config in the repo and Sync — changes made here are overwritten on the next sync.",
|
||||
"gateFieldsLabel": "Managed fields",
|
||||
"field": {
|
||||
"port": "Port",
|
||||
"healthcheck": "Healthcheck",
|
||||
"deploy_strategy": "Deploy strategy"
|
||||
}
|
||||
},
|
||||
"toolbar": {
|
||||
"stop": "Stop",
|
||||
"start": "Start",
|
||||
|
||||
@@ -1407,7 +1407,9 @@
|
||||
"colTrigger": "Триггер",
|
||||
"colCreated": "Создано",
|
||||
"colActions": "Действия",
|
||||
"rowOpen": "Открыть"
|
||||
"rowOpen": "Открыть",
|
||||
"gitopsBadge": "GitOps",
|
||||
"gitopsBadgeTitle": "Конфигурация деплоя этого приложения управляется из репозитория."
|
||||
},
|
||||
"new": {
|
||||
"pageTitle": "Новое приложение · Tinyforge",
|
||||
@@ -1731,6 +1733,62 @@
|
||||
"cpuSeries": "CPU",
|
||||
"memorySeries": "Память"
|
||||
},
|
||||
"gitops": {
|
||||
"title": "GitOps",
|
||||
"sub": "Опишите конфигурацию деплоя в репозитории и применяйте её по запросу.",
|
||||
"loading": "Загрузка статуса GitOps…",
|
||||
"disabledLead": "GitOps для этого приложения отключён.",
|
||||
"disabledSub": "Включите его, чтобы управлять конфигурацией деплоя из",
|
||||
"badge": "GitOps",
|
||||
"badgeTitle": "Конфигурация деплоя этого приложения управляется из репозитория.",
|
||||
"toggleLabel": "Включено",
|
||||
"toggleAria": "Включить GitOps для этого приложения",
|
||||
"toggleHint": "Читать конфигурацию деплоя из файла в репозитории приложения.",
|
||||
"enabledToast": "GitOps включён.",
|
||||
"disabledToast": "GitOps отключён.",
|
||||
"toggleFailed": "Не удалось обновить настройки GitOps.",
|
||||
"pillSynced": "Синхронизировано",
|
||||
"pillChangesOne": "1 изменение",
|
||||
"pillChangesMany": "изменений: {count}",
|
||||
"pillNoFile": "Нет файла",
|
||||
"pillFetchFailed": "Ошибка загрузки",
|
||||
"pillInvalid": "Некорректно",
|
||||
"pillDisabled": "Отключено",
|
||||
"metaPath": "Путь",
|
||||
"metaCommit": "Коммит",
|
||||
"metaLastSync": "Последняя синхр.",
|
||||
"metaNeverSynced": "Не синхронизировано",
|
||||
"inSyncTitle": "Текущая конфигурация совпадает с репозиторием",
|
||||
"inSyncSub": "Все объявленные поля уже применены. Синхронизировать нечего.",
|
||||
"driftAria": "Поля, отличающиеся между файлом в репозитории и текущей конфигурацией",
|
||||
"driftColField": "Поле",
|
||||
"driftColRepo": "Репозиторий",
|
||||
"driftColLive": "Текущее",
|
||||
"driftFoot": "Синхронизация применит значения из репозитория, заменив текущие выше.",
|
||||
"noFileLead": "В ветке нет файла конфигурации",
|
||||
"noFileSub": "GitOps включён, но ничего не найдено по пути",
|
||||
"fetchFailedLead": "Не удалось прочитать конфигурацию из репозитория",
|
||||
"invalidLead": "Конфигурацию из репозитория не удалось разобрать",
|
||||
"copy": "Копировать",
|
||||
"copied": "Скопировано",
|
||||
"copyAria": "Скопировать файл конфигурации в буфер обмена",
|
||||
"syncHintDrift": "Синхронизируйте, чтобы применить конфигурацию из репозитория к текущей.",
|
||||
"syncHintClean": "Текущая конфигурация уже совпадает с репозиторием.",
|
||||
"syncNow": "Синхронизировать",
|
||||
"syncing": "Синхронизация…",
|
||||
"syncedToast": "Применено полей: {count} из {sha}.",
|
||||
"syncFailed": "Ошибка синхронизации.",
|
||||
"confirmTitle": "Применить конфигурацию из репозитория?",
|
||||
"confirmMessage": "Конфигурация из репозитория будет применена к текущей конфигурации приложения. Текущие значения объявленных полей будут заменены.",
|
||||
"gateTitle": "Управляется через .tinyforge.yml",
|
||||
"gateBody": "Редактируйте конфигурацию в репозитории и синхронизируйте — изменения здесь будут перезаписаны при следующей синхронизации.",
|
||||
"gateFieldsLabel": "Управляемые поля",
|
||||
"field": {
|
||||
"port": "Порт",
|
||||
"healthcheck": "Healthcheck",
|
||||
"deploy_strategy": "Стратегия деплоя"
|
||||
}
|
||||
},
|
||||
"toolbar": {
|
||||
"stop": "Стоп",
|
||||
"start": "Старт",
|
||||
|
||||
@@ -372,6 +372,13 @@ export interface Workload {
|
||||
parent_workload_id: string;
|
||||
notification_url: string;
|
||||
webhook_require_signature: boolean;
|
||||
// GitOps config-as-code (dockerfile/static sources only). Opt-in: when
|
||||
// enabled, the workload's deploy config is declared in `gitops_path`
|
||||
// (default ".tinyforge.yml") in its own repo and applied via Sync.
|
||||
gitops_enabled: boolean;
|
||||
gitops_path: string;
|
||||
gitops_last_sync_at: string;
|
||||
gitops_commit_sha: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
@@ -169,6 +169,11 @@
|
||||
<span class="badge {sourceBadge(w.source_kind)}">
|
||||
<span class="badge-dot" aria-hidden="true"></span>{w.source_kind}
|
||||
</span>
|
||||
{#if w.gitops_enabled && (w.source_kind === 'dockerfile' || w.source_kind === 'static')}
|
||||
<span class="badge badge-gitops" title={$t('apps.list.gitopsBadgeTitle')}>
|
||||
<span class="badge-dot" aria-hidden="true"></span>{$t('apps.list.gitopsBadge')}
|
||||
</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-trigger">{w.trigger_kind}</span>
|
||||
@@ -506,6 +511,16 @@
|
||||
background: var(--surface-card-hover);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
/* GitOps-managed chip — ember-tinted, sits beside the source badge. */
|
||||
.badge-gitops {
|
||||
margin-left: 0.35rem;
|
||||
background: var(--forge-accent-soft);
|
||||
color: var(--color-brand-700);
|
||||
border-color: color-mix(in srgb, var(--color-brand-500) 35%, transparent);
|
||||
}
|
||||
:global([data-theme='dark']) .badge-gitops {
|
||||
color: var(--color-brand-300);
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--text-tertiary);
|
||||
|
||||
@@ -23,7 +23,8 @@
|
||||
IconGlobe,
|
||||
IconHardDrive,
|
||||
IconClock,
|
||||
IconLoader
|
||||
IconLoader,
|
||||
IconLock
|
||||
} from '$lib/components/icons';
|
||||
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||
@@ -35,6 +36,7 @@
|
||||
import WorkloadSnapshotsPanel from '$lib/components/WorkloadSnapshotsPanel.svelte';
|
||||
import DeployHistoryPanel from '$lib/components/DeployHistoryPanel.svelte';
|
||||
import WorkloadMetricsPanel from '$lib/components/WorkloadMetricsPanel.svelte';
|
||||
import GitOpsPanel from '$lib/components/GitOpsPanel.svelte';
|
||||
import TriggerKindForm, {
|
||||
createTriggerKindFormState,
|
||||
isTriggerFormValid,
|
||||
@@ -139,6 +141,19 @@
|
||||
// plain text input if the request fails. Bound into ImageSourceForm.
|
||||
let editRegistries = $state<{ name: string; url: string }[]>([]);
|
||||
|
||||
// Source-config keys the repo's .tinyforge.yml declares for a
|
||||
// GitOps-managed workload. Fetched (fire-and-forget) when the edit form
|
||||
// opens on a gitops_enabled workload; drives the read-only gate banner that
|
||||
// warns these fields are overwritten on the next Sync. Empty = no gate.
|
||||
let gitopsManagedFields = $state<string[]>([]);
|
||||
// Viewer role drives whether the GitOps panel offers its admin affordances
|
||||
// (enable toggle, Sync). Fetched once; the server also enforces AdminOnly.
|
||||
let isAdmin = $state(false);
|
||||
let roleFetched = false;
|
||||
const editGitOpsGated = $derived(
|
||||
(workload?.gitops_enabled ?? false) && gitopsManagedFields.length > 0
|
||||
);
|
||||
|
||||
// A cleared <input type="number"> binds to null (not 0) in Svelte 5, and
|
||||
// `null <= 0` is false — so a bare `port <= 0` check would pass validation
|
||||
// on an empty field and POST `port: null`. The module's isDockerfileValid
|
||||
@@ -1209,6 +1224,23 @@
|
||||
.catch(() => {
|
||||
editRegistries = [];
|
||||
});
|
||||
// Fire-and-forget: surface which fields the repo config manages so the
|
||||
// edit form can warn they'll be overwritten on Sync. Only relevant when
|
||||
// GitOps is on; failure leaves the gate off (no banner) — never blocks.
|
||||
gitopsManagedFields = [];
|
||||
if (workload.gitops_enabled) {
|
||||
// Guard against a slow response landing on a different workload after
|
||||
// an A→B nav (the component instance is reused).
|
||||
const forId = workload.id;
|
||||
void api
|
||||
.fetchWorkloadGitOps(forId)
|
||||
.then((g) => {
|
||||
if (forId === workload?.id) gitopsManagedFields = g.managed_fields ?? [];
|
||||
})
|
||||
.catch(() => {
|
||||
if (forId === workload?.id) gitopsManagedFields = [];
|
||||
});
|
||||
}
|
||||
editing = true;
|
||||
}
|
||||
|
||||
@@ -1492,6 +1524,18 @@
|
||||
// fetch for the previous id cannot land on the new id's state.
|
||||
$effect(() => {
|
||||
const _ = id; // explicit dependency
|
||||
// Resolve the viewer's role once (role is session-wide, not per-id).
|
||||
if (!roleFetched) {
|
||||
roleFetched = true;
|
||||
void api
|
||||
.getCurrentUser()
|
||||
.then((u) => {
|
||||
isAdmin = u.role === 'admin';
|
||||
})
|
||||
.catch(() => {
|
||||
isAdmin = false;
|
||||
});
|
||||
}
|
||||
runtimeAbort?.abort();
|
||||
storageAbort?.abort();
|
||||
triggersAbort?.abort();
|
||||
@@ -1706,6 +1750,12 @@
|
||||
<span class="badge-dot" aria-hidden="true"></span>
|
||||
{workload!.source_kind}
|
||||
</span>
|
||||
{#if workload!.gitops_enabled}
|
||||
<span class="badge badge-gitops" title={$t('apps.detail.gitops.badgeTitle')}>
|
||||
<span class="badge-dot" aria-hidden="true"></span>
|
||||
{$t('apps.detail.gitops.badge')}
|
||||
</span>
|
||||
{/if}
|
||||
<span class="badge trigger">
|
||||
{bindings.length === 0
|
||||
? $t('apps.detail.chainTriggersZero')
|
||||
@@ -1750,6 +1800,26 @@
|
||||
</span>
|
||||
</header>
|
||||
|
||||
{#if editGitOpsGated}
|
||||
<!-- Read-only gate: this workload's config is declared in the repo.
|
||||
Edits here are valid but get overwritten on the next Sync, so
|
||||
the banner steers the user to the file + lists the managed
|
||||
fields. Calm (accent, not danger) — it's guidance, not an error. -->
|
||||
<div class="gitops-gate" role="note">
|
||||
<span class="gitops-gate-icon" aria-hidden="true"><IconLock size={15} /></span>
|
||||
<div class="gitops-gate-body">
|
||||
<p class="gitops-gate-title">{$t('apps.detail.gitops.gateTitle')}</p>
|
||||
<p class="gitops-gate-text">{$t('apps.detail.gitops.gateBody')}</p>
|
||||
<div class="gitops-gate-fields">
|
||||
<span class="gitops-gate-flabel">{$t('apps.detail.gitops.gateFieldsLabel')}</span>
|
||||
{#each gitopsManagedFields as f (f)}
|
||||
<code class="gitops-gate-field">{f}</code>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="field">
|
||||
<label for="edit-name" class="field-label">
|
||||
<span class="num">01</span>
|
||||
@@ -2820,6 +2890,16 @@
|
||||
<DeployHistoryPanel workloadId={id} />
|
||||
{/if}
|
||||
|
||||
<!-- ── GitOps config-as-code (dockerfile/static) ──── -->
|
||||
{#if !editing && (workload.source_kind === 'dockerfile' || workload.source_kind === 'static')}
|
||||
<GitOpsPanel
|
||||
workloadId={id}
|
||||
sourceKind={workload.source_kind}
|
||||
{isAdmin}
|
||||
onSynced={load}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- ── Per-workload notification routes ───────────── -->
|
||||
{#if !editing}
|
||||
<WorkloadNotificationsPanel workloadId={id} />
|
||||
@@ -3477,6 +3557,81 @@
|
||||
background: var(--surface-card-hover);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
/* GitOps-managed chip: ember-tinted so it reads as "this app's config
|
||||
is driven from the repo", visually allied with the accent. */
|
||||
.badge-gitops {
|
||||
background: var(--forge-accent-soft);
|
||||
color: var(--color-brand-700);
|
||||
border-color: color-mix(in srgb, var(--color-brand-500) 35%, transparent);
|
||||
}
|
||||
:global([data-theme='dark']) .badge-gitops {
|
||||
color: var(--color-brand-300);
|
||||
}
|
||||
|
||||
/* ── GitOps read-only gate banner (edit form) ──── */
|
||||
.gitops-gate {
|
||||
display: flex;
|
||||
gap: 0.7rem;
|
||||
padding: 0.85rem 1rem;
|
||||
border-radius: var(--radius-lg);
|
||||
background: color-mix(in srgb, var(--color-brand-500) 7%, var(--surface-card));
|
||||
border: 1px solid color-mix(in srgb, var(--color-brand-500) 30%, transparent);
|
||||
}
|
||||
.gitops-gate-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--radius-md);
|
||||
color: #fff;
|
||||
background: var(--color-brand-600);
|
||||
}
|
||||
.gitops-gate-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.gitops-gate-title {
|
||||
margin: 0;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.gitops-gate-text {
|
||||
margin: 0;
|
||||
font-size: 0.74rem;
|
||||
line-height: 1.5;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.gitops-gate-fields {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
.gitops-gate-flabel {
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.gitops-gate-field {
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.7rem;
|
||||
padding: 0.1rem 0.42rem;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--forge-accent-soft);
|
||||
border: 1px solid color-mix(in srgb, var(--color-brand-500) 30%, transparent);
|
||||
color: var(--color-brand-700);
|
||||
}
|
||||
:global([data-theme='dark']) .gitops-gate-field {
|
||||
color: var(--color-brand-300);
|
||||
}
|
||||
|
||||
/* ── Runtime / storage panels ────────────────────
|
||||
Two narrow operational-status cards displayed in a 2-up grid
|
||||
|
||||
Reference in New Issue
Block a user