Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions src/components/agents/config/CodeLabel.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<template>
<span v-if="label" :title="code ?? undefined">{{ label }}</span>
<code v-else-if="code" class="font-mono text-xs">{{ code }}</code>
</template>

<script setup lang="ts">
import { computed } from 'vue';
import { labelFor } from './constants';

// Renders a server code through a label table; an unknown code renders verbatim in
// monospace so a new server code never breaks the UI.
const props = defineProps<{
labels: Record<string, string>;
code?: string | null;
}>();

const label = computed(() => labelFor(props.labels, props.code));
</script>
32 changes: 32 additions & 0 deletions src/components/agents/config/ConfigPill.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<template>
<span
class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium whitespace-nowrap"
:class="classes"
>
<slot />
</span>
</template>

<script setup lang="ts">
import { computed } from 'vue';

const props = withDefaults(
defineProps<{
severity?: 'success' | 'warn' | 'danger' | 'secondary' | 'info';
}>(),
{ severity: 'secondary' },
);

const classes = computed(
() =>
({
success:
'bg-green-100 text-green-700 dark:bg-green-500/15 dark:text-green-300',
warn: 'bg-amber-100 text-amber-700 dark:bg-amber-500/15 dark:text-amber-300',
danger: 'bg-red-100 text-red-700 dark:bg-red-500/15 dark:text-red-300',
info: 'bg-sky-100 text-sky-700 dark:bg-sky-500/15 dark:text-sky-300',
secondary:
'bg-slate-200 text-slate-700 dark:bg-slate-700 dark:text-slate-300',
})[props.severity],
);
</script>
96 changes: 96 additions & 0 deletions src/components/agents/config/ConfigYamlViewer.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<template>
<div class="space-y-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<p v-if="legend" class="text-sm text-gray-500 dark:text-slate-400">
<i class="pi pi-lock mr-1 text-xs" />{{ legend }}
</p>
<span v-else />
<div class="flex gap-2">
<SecondaryButton
size="small"
:disabled="isEmpty"
data-test="yaml-copy"
@click="copy"
>
<i class="pi pi-copy mr-2" />Copy
</SecondaryButton>
<SecondaryButton
size="small"
:disabled="isEmpty"
data-test="yaml-download"
@click="download"
>
<i class="pi pi-download mr-2" />Download
</SecondaryButton>
</div>
</div>
<p
v-if="isEmpty"
class="rounded-md border border-dashed border-ccf-300 p-4 text-sm text-gray-500 dark:border-slate-700 dark:text-slate-400"
data-test="yaml-empty"
>
{{ emptyText }}
</p>
<div
v-else
class="max-h-[600px] overflow-auto rounded-lg border bg-gray-900 p-4 text-gray-100"
>
<pre
class="font-mono text-sm whitespace-pre-wrap"
data-test="yaml-text"
>{{ text }}</pre
>
</div>
</div>
</template>

<script setup lang="ts">
import { computed } from 'vue';
import { useToast } from 'primevue/usetoast';
import SecondaryButton from '@/volt/SecondaryButton.vue';
import { toYaml } from '@/utils/agent-config/yaml';

const props = defineProps<{
doc: object | null;
filename: string;
emptyText: string;
legend?: string;
}>();

const toast = useToast();

const isEmpty = computed(() => props.doc === null || props.doc === undefined);
const text = computed(() => (isEmpty.value ? '' : toYaml(props.doc)));

async function copy() {
try {
if (!navigator.clipboard) throw new Error('Clipboard API unavailable');
await navigator.clipboard.writeText(text.value);
toast.add({
severity: 'success',
summary: 'Copied',
detail: 'YAML copied to the clipboard.',
life: 2500,
});
} catch {
toast.add({
severity: 'error',
summary: 'Copy Failed',
detail: 'Unable to copy to the clipboard.',
life: 3000,
});
}
}

function download() {
const blob = new Blob([text.value], { type: 'application/yaml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = props.filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
</script>
214 changes: 214 additions & 0 deletions src/components/agents/config/InstanceModeNotice.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
<template>
<section
v-if="instance && state"
class="space-y-3 rounded-md border border-ccf-300 p-4 text-sm dark:border-slate-700"
data-test="mode-notice"
>
<div class="flex flex-wrap items-center gap-2">
<span class="font-medium text-gray-900 dark:text-slate-200">
{{ instance.hostname || instance.instanceId }}
</span>
<ConfigPill v-if="instance.mode" severity="info" data-test="mode-badge">
{{ instance.mode }}
</ConfigPill>
<InstanceStatusChip :state="state" />
<ConfigPill
v-for="badge in state.badges"
:key="badge.key"
v-tooltip.top="badge.tooltip"
:severity="badge.severity"
:data-badge="badge.key"
>
{{ badge.label }}
</ConfigPill>
<span
v-if="instance.agentVersion"
class="text-xs text-gray-500 dark:text-slate-400"
>
agent {{ instance.agentVersion }}
</span>
</div>

<!-- Mode explanation (§6.1) -->
<p
v-if="state.state === 'not-reported'"
class="text-gray-600 dark:text-slate-400"
>
{{ NOT_REPORTED_TEXT }}
</p>
<p
v-else-if="instance.mode === 'report'"
class="text-gray-600 dark:text-slate-400"
>
{{ MODE_TEXT.report }}
</p>
<template v-else-if="instance.mode === 'apply_safe'">
<p class="text-gray-600 dark:text-slate-400">
{{ MODE_TEXT.apply_safe }}
</p>
<div
class="flex flex-wrap items-center gap-1"
data-test="trusted-sources"
>
<span class="text-xs text-gray-500 dark:text-slate-400"
>Trusted sources:</span
>
<span
v-if="!trusted.length"
class="text-xs text-gray-500 dark:text-slate-400"
>none</span
>
<code
v-for="s in trusted"
:key="s"
class="rounded bg-slate-200 px-1.5 text-xs dark:bg-slate-700"
>{{ s }}</code
>
</div>
<div
class="flex flex-wrap items-center gap-1"
data-test="overridable-flags"
>
<span class="text-xs text-gray-500 dark:text-slate-400"
>Overridable config keys:</span
>
<span
v-if="!overridable.length"
class="text-xs text-gray-500 dark:text-slate-400"
>none</span
>
<code
v-for="s in overridable"
:key="s"
class="rounded bg-slate-200 px-1.5 text-xs dark:bg-slate-700"
>{{ s }}</code
>
</div>
</template>
<template v-else-if="instance.mode === 'apply_all'">
<p class="text-gray-600 dark:text-slate-400">{{ MODE_TEXT.apply_all }}</p>
<p
v-if="rc.allow_local_sources"
class="text-gray-600 dark:text-slate-400"
>
Local sources allowed.
</p>
</template>

<!-- Status details (U1.3 table) -->
<p
v-if="state.state === 'pending'"
class="text-amber-700 dark:text-amber-300"
>
Picks up changes within its poll interval
(<code>remote_config.poll_interval</code>,
{{ rc.poll_interval ?? 'default 60 s' }}).
</p>
<div
v-if="state.problem"
class="space-y-2 rounded-md bg-red-50 p-3 dark:bg-red-500/10"
data-test="rejection-details"
>
<p
v-if="state.state === 'rejected-forbidden'"
class="text-red-700 dark:text-red-300"
>
Forbidden changes are rejected in every mode.
</p>
<p
v-if="instance.reason && !isUnsafeRow"
class="text-red-700 dark:text-red-300"
>
<CodeLabel :labels="APPLY_REASON_LABELS" :code="instance.reason" />
</p>
<p
v-if="instance.error"
class="font-mono text-xs break-all text-red-700 dark:text-red-300"
>
{{ instance.error }}
</p>
<ul v-if="isUnsafeRow && instance.unsafe.length" class="space-y-1">
<li
v-for="c in instance.unsafe"
:key="`${c.path}|${c.reason}|${c.value ?? ''}`"
class="flex flex-wrap gap-2 text-xs"
>
<code class="font-mono">{{ c.path }}</code>
<span>·</span>
<CodeLabel :labels="CHANGE_REASON_LABELS" :code="c.reason" />
<template v-if="c.value">
<span>·</span><code class="font-mono break-all">{{ c.value }}</code>
</template>
</li>
</ul>
<p
v-if="
instance.appliedRevision !== null &&
state.state !== 'rejected-invalid'
"
class="text-xs text-gray-600 dark:text-slate-400"
>
running r{{ instance.appliedRevision }}
</p>
</div>

<!-- R41 file warnings -->
<div
v-if="instance.warnings?.length"
class="rounded-md bg-amber-50 p-3 dark:bg-amber-500/10"
data-test="file-warnings"
>
<p class="mb-1 text-xs font-medium text-amber-800 dark:text-amber-300">
Problems in this agent's local file (tolerated; the affected plugins are
skipped)
</p>
<ul class="space-y-1 text-xs">
<li
v-for="(w, i) in instance.warnings"
:key="i"
class="flex flex-wrap gap-2"
>
<code class="font-mono">{{ w.path || '/' }}</code>
<span v-if="w.code" class="text-gray-500 dark:text-slate-400">
<CodeLabel :labels="FIELD_ERROR_CODE_LABELS" :code="w.code" />
</span>
<span>{{ w.message }}</span>
</li>
</ul>
</div>
</section>
</template>

<script setup lang="ts">
import { computed } from 'vue';
import type {
AgentInstanceSummary,
RemoteConfigDoc,
} from '@/types/agent-config';
import type { InstanceUiState } from '@/utils/agent-config/instance-status';
import ConfigPill from './ConfigPill.vue';
import CodeLabel from './CodeLabel.vue';
import InstanceStatusChip from './InstanceStatusChip.vue';
import {
APPLY_REASON_LABELS,
CHANGE_REASON_LABELS,
FIELD_ERROR_CODE_LABELS,
MODE_TEXT,
NOT_REPORTED_TEXT,
} from './constants';

const props = defineProps<{
instance: AgentInstanceSummary | null;
state: InstanceUiState | null;
}>();

// R29 defaults when keys are absent.
const rc = computed<RemoteConfigDoc>(() => props.instance?.remoteConfig ?? {});
const trusted = computed(() => rc.value.trusted_sources ?? []);
const overridable = computed(() => rc.value.overridable_config_flags ?? []);
const isUnsafeRow = computed(
() =>
props.state?.state === 'rejected-unsafe' ||
props.state?.state === 'rejected-forbidden',
);
</script>
17 changes: 17 additions & 0 deletions src/components/agents/config/InstanceStatusChip.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<template>
<ConfigPill
:severity="state.severity"
:data-state="state.state"
:title="state.chipLabel"
>
<i v-if="state.problem" class="pi pi-exclamation-triangle text-[0.7rem]" />
{{ state.chipLabel }}
</ConfigPill>
</template>

<script setup lang="ts">
import ConfigPill from './ConfigPill.vue';
import type { InstanceUiState } from '@/utils/agent-config/instance-status';

defineProps<{ state: InstanceUiState }>();
</script>
Loading
Loading