Add unit tests for release manager i18n validation and implement ReleaseReplayInspector Vue component for timeline inspection and replay functionality.

This commit is contained in:
Jeppe Bundgaard
2026-05-19 14:46:42 +02:00
parent 8378491b0b
commit 42237fcc70
28 changed files with 2004 additions and 558 deletions
@@ -7,9 +7,17 @@ import {
releaseChannelSwitchedStatus,
} from "@/services/releaseChannelAvailability.js";
const { t } = useI18n({ useScope: "global" });
const { t, te } = useI18n({ useScope: "global" });
const tr = (key, params = {}) => t(`configuration.release_manager.channel_switched.${key}`, params);
const status = computed(() => releaseChannelSwitchedStatus.value);
const localizedChannelName = computed(() => {
const key = `configuration.release_manager.channel_names.${status.value.channelSlug}`;
return status.value.channelSlug && te(key) ? t(key) : status.value.channelName;
});
const localizedChannelDescription = computed(() => {
const key = `configuration.release_manager.channel_descriptions.${status.value.channelSlug}`;
return status.value.channelSlug && te(key) ? t(key) : status.value.description;
});
const frontendLabel = computed(() => status.value.frontendBaseUrl || tr("current_app_image"));
const apiLabel = computed(() => status.value.apiBaseUrl || tr("current_api"));
const frontendVersion = computed(() => status.value.versions?.frontend || null);
@@ -42,25 +50,37 @@ const continueToApp = () => {
</div>
<div class="release-card release-card--channel">
<i class="fas fa-code-branch"></i>
<span>{{ status.channelSlug || "channel" }}</span>
<span>{{ localizedChannelName }}</span>
</div>
</div>
<div class="release-switch-copy">
<p class="release-kicker">{{ tr("kicker") }}</p>
<h1>{{ tr("title", { channel: status.channelName }) }}</h1>
<h1>{{ tr("title", { channel: localizedChannelName }) }}</h1>
<p class="release-summary">
{{ tr("summary_prefix") }} <strong>{{ status.channelName }}</strong> {{ tr("summary_suffix") }}
{{ tr("summary_prefix") }} <strong>{{ localizedChannelName }}</strong> {{ tr("summary_suffix") }}
</p>
<p v-if="status.description" class="release-description">
{{ status.description }}
<p v-if="localizedChannelDescription" class="release-description">
{{ localizedChannelDescription }}
</p>
<div class="release-details" data-testid="release-channel-switched-details">
<span><strong>{{ tr("frontend") }}</strong>{{ frontendLabel }}</span>
<span><strong>{{ tr("api") }}</strong>{{ apiLabel }}</span>
<span><strong>{{ tr("frontend_version") }}</strong>{{ versionLabel(frontendVersion, tr("assigned_channel")) }}</span>
<span><strong>{{ tr("api_version") }}</strong>{{ versionLabel(apiVersion, tr("assigned_channel")) }}</span>
<span
><strong>{{ tr("frontend") }}</strong
>{{ frontendLabel }}</span
>
<span
><strong>{{ tr("api") }}</strong
>{{ apiLabel }}</span
>
<span
><strong>{{ tr("frontend_version") }}</strong
>{{ versionLabel(frontendVersion, tr("assigned_channel")) }}</span
>
<span
><strong>{{ tr("api_version") }}</strong
>{{ versionLabel(apiVersion, tr("assigned_channel")) }}</span
>
</div>
<div class="release-actions">
@@ -11,7 +11,7 @@ import {
} from "@/services/releaseChannelAvailability.js";
const router = useRouter();
const { t } = useI18n({ useScope: "global" });
const { t, te } = useI18n({ useScope: "global" });
const tr = (key, params = {}) => t(`configuration.release_manager.channel_unavailable.${key}`, params);
const checking = ref(false);
const checkError = ref("");
@@ -21,6 +21,14 @@ let pollTimer = null;
let countdownTimer = null;
const status = computed(() => releaseChannelUnavailableStatus.value);
const localizedChannelName = computed(() => {
const key = `configuration.release_manager.channel_names.${status.value.channelSlug}`;
return status.value.channelSlug && te(key) ? t(key) : status.value.channelName;
});
const localizedChannelDescription = computed(() => {
const key = `configuration.release_manager.channel_descriptions.${status.value.channelSlug}`;
return status.value.channelSlug && te(key) ? t(key) : status.value.description;
});
const missingLabels = computed(() =>
status.value.missing.map((key) =>
key === "frontend_base_url" ? tr("frontend_url") : key === "api_base_url" ? tr("api_url") : key
@@ -91,7 +99,7 @@ onBeforeUnmount(() => {
</div>
<div class="release-card release-card--channel">
<i class="fas fa-code-branch"></i>
<span>{{ status.channelSlug || "channel" }}</span>
<span>{{ localizedChannelName }}</span>
</div>
<div class="release-card release-card--base">
<i class="fas fa-layer-group"></i>
@@ -103,11 +111,11 @@ onBeforeUnmount(() => {
<p class="release-kicker">{{ tr("kicker") }}</p>
<h1>{{ tr("title") }}</h1>
<p class="release-summary">
{{ tr("summary_prefix") }} <strong>{{ status.channelName }}</strong
{{ tr("summary_prefix") }} <strong>{{ localizedChannelName }}</strong
>{{ tr("summary_suffix") }}
</p>
<p v-if="status.description" class="release-description">
{{ status.description }}
<p v-if="localizedChannelDescription" class="release-description">
{{ localizedChannelDescription }}
</p>
<div class="release-missing" data-testid="release-channel-missing">
<span v-for="label in missingLabels" :key="label">{{ label }}</span>
@@ -122,9 +130,9 @@ onBeforeUnmount(() => {
type="is-dark"
icon-left="clock"
icon-pack="fas"
data-testid="release-channel-ignore"
@click="ignoreForFiveMinutes"
>
data-testid="release-channel-ignore"
@click="ignoreForFiveMinutes"
>
{{ tr("ignore") }}
</b-button>
<b-button
@@ -132,9 +140,9 @@ onBeforeUnmount(() => {
icon-left="sync-alt"
icon-pack="fas"
:loading="checking"
data-testid="release-channel-check-again"
@click="checkAgain"
>
data-testid="release-channel-check-again"
@click="checkAgain"
>
{{ tr("check_again") }}
</b-button>
<b-button type="is-danger is-light" icon-left="sign-out-alt" icon-pack="fas" @click="logout">
@@ -0,0 +1,751 @@
<script setup>
import { computed, onMounted, reactive, ref } from "vue";
import { BAutocomplete } from "buefy";
import { useI18n } from "vue-i18n";
import ActionSettingsWheelButton from "@/components/displays/buttons/ActionSettingsWheelButton.vue";
import ActionSettingsWheelItem from "@/components/displays/buttons/ActionSettingsWheelItem.vue";
import {
getReleaseTimelineSession,
listReleaseTimelineSessions,
searchReleaseTimeline,
setReleaseReplayTarget,
} from "@/services/superuserReleases.js";
const props = defineProps({
channels: {
type: Array,
default: () => [],
},
moduleKeys: {
type: Array,
default: () => [],
},
canReplay: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["refresh-summary"]);
const { t } = useI18n({ useScope: "global" });
const tr = (key, params = {}) => t(`configuration.release_manager.${key}`, params);
const trFallback = (key, fallback, params = {}) => {
const fullKey = `configuration.release_manager.${key}`;
const translated = t(fullKey, params);
return translated === fullKey ? fallback : translated;
};
const valueLabel = (group, value) => trFallback(`values.${group}.${value}`, String(value || ""));
const replayForm = reactive({
target_type: "user",
target_id: "",
channel_id: null,
capture_level: "full_redacted",
expires_at: "",
});
const timelineFilters = reactive({
trace_id: "",
channel_slug: "",
principal_type: "",
principal_id: "",
customer_number: "",
module_key: "",
severity: "",
event_type: "",
device_type: "",
frontend_version: "",
api_version: "",
date_from: "",
date_to: "",
has_error_report: false,
limit: 100,
});
const busy = ref("");
const errors = ref([]);
const timelineEvents = ref([]);
const timelineSessions = ref([]);
const selectedDetail = ref(null);
const selectedDetailTab = ref("events");
const responseData = (response, fallback) => response?.data?.data ?? fallback;
const parseError = (error) =>
error?.response?.data?.data?.message || error?.response?.data?.message || error?.message || "Unknown error";
const channelOptions = computed(() => props.channels || []);
const moduleOptions = computed(() => {
const query = String(timelineFilters.module_key || "").toLowerCase();
return (props.moduleKeys || [])
.map((key) => ({
value: key,
title: key,
description: trFallback("autocomplete.timeline_module_key", "Timeline module"),
icon: "fas fa-puzzle-piece",
}))
.filter((option) => !query || option.value.toLowerCase().includes(query))
.slice(0, 10);
});
const sessionRows = computed(() => (Array.isArray(timelineSessions.value) ? timelineSessions.value : []));
const selectedSession = computed(() => selectedDetail.value?.session || null);
const detailEvents = computed(() =>
Array.isArray(selectedDetail.value?.events) ? selectedDetail.value.events : []
);
const detailErrorReports = computed(() =>
Array.isArray(selectedDetail.value?.error_reports) ? selectedDetail.value.error_reports : []
);
const selectedRelease = computed(() => selectedDetail.value?.release || selectedSession.value?.release || {});
const filterPayload = () => {
const payload = {};
for (const [key, value] of Object.entries(timelineFilters)) {
if (value === "" || value === null || value === false) {
continue;
}
payload[key] = value;
}
return payload;
};
const statusClass = (status) => ({
"is-ok": ["ok", "active", "deployed", "promoted", "ready", "stable", "enabled", "info"].includes(status),
"is-degraded": ["queued", "deploying", "draft", "metadata", "warning"].includes(status),
"is-down": ["failed", "down", "error"].includes(status),
});
const formatDate = (value) => {
if (!value) {
return "--";
}
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
};
const shortValue = (value) => {
const text = String(value || "");
return text.length > 16 ? `${text.slice(0, 12)}...` : text || "--";
};
const userLabel = (session) => {
const user = session?.user || {};
return [user.label, user.customer_number ? `Customer ${user.customer_number}` : null].filter(Boolean).join(" / ") || "--";
};
const releaseLabel = (session, app) => {
const release = session?.release?.[app] || {};
return release.version_label || release.commit_sha || "--";
};
const releaseApp = (app) => selectedRelease.value?.[app] || selectedSession.value?.release?.[app] || {};
const versionReferenceLabel = (app) => {
const version = releaseApp(app)?.version;
return version?.id ? `#${version.id} ${version.status || ""}`.trim() : "--";
};
const deploymentReferenceLabel = (app) => {
const deployment = releaseApp(app)?.deployment;
return deployment?.id ? `#${deployment.id} ${deployment.status || ""}`.trim() : "--";
};
const bundleReferenceLabel = () => {
const bundle = selectedRelease.value?.bundle;
return bundle?.id ? `#${bundle.id} ${bundle.version_label || bundle.status || ""}`.trim() : "--";
};
const deviceLabel = (session) => {
const device = session?.device || {};
return [device.type, device.browser_name, device.os_name].filter(Boolean).join(" / ") || "--";
};
const viewportLabel = (session) => {
const device = session?.device || {};
if (!device.viewport_width || !device.viewport_height) {
return "--";
}
return `${device.viewport_width}x${device.viewport_height} @ ${device.device_pixel_ratio || 1}`;
};
const jsonPreview = (value) => {
if (value === null || value === undefined || value === "") {
return "--";
}
return JSON.stringify(value, null, 2);
};
async function run(key, callback) {
busy.value = key;
errors.value = [];
try {
await callback();
} catch (error) {
errors.value.push(error);
} finally {
busy.value = "";
}
}
async function enableReplayTarget() {
await run("replay:target", async () => {
await setReleaseReplayTarget({ ...replayForm, enabled: true });
Object.assign(replayForm, { target_id: "", expires_at: "" });
emit("refresh-summary");
await loadReplayData();
});
}
async function loadReplayData() {
await run("timeline:search", async () => {
const filters = filterPayload();
const [sessionsResponse, eventsResponse] = await Promise.all([
listReleaseTimelineSessions(filters),
searchReleaseTimeline(filters),
]);
timelineSessions.value = responseData(sessionsResponse, []);
timelineEvents.value = responseData(eventsResponse, []);
if (selectedSession.value && !timelineSessions.value.some((session) => session.trace_id === selectedSession.value.trace_id)) {
selectedDetail.value = null;
}
});
}
async function inspectSession(session) {
if (!session?.trace_id) {
return;
}
await run(`session:${session.trace_id}`, async () => {
selectedDetail.value = responseData(await getReleaseTimelineSession(session.trace_id), null);
selectedDetailTab.value = "events";
});
}
function applyChannelTarget(channel) {
Object.assign(replayForm, { target_type: "channel", target_id: channel.slug, channel_id: channel.id });
}
function selectModule(option) {
timelineFilters.module_key = option?.value || option || "";
}
function closeDetailModal() {
selectedDetail.value = null;
selectedDetailTab.value = "events";
}
onMounted(loadReplayData);
</script>
<template>
<div class="release-replay-inspector">
<div v-if="errors.length" class="notification is-danger is-light">
<p v-for="(error, index) in errors" :key="index">{{ parseError(error) }}</p>
</div>
<div class="release-chip-row" data-testid="release-replay-target-suggestions">
<b-button
v-for="channel in channelOptions"
:key="channel.id"
size="is-small"
type="is-light"
icon-left="circle"
icon-pack="fas"
@click="applyChannelTarget(channel)"
>
{{ tr("replay.capture_channel", { channel: channel.slug }) }}
</b-button>
</div>
<form class="release-form" data-testid="release-replay-form" @submit.prevent="enableReplayTarget">
<b-field :label="tr('replay.target_type')">
<b-select v-model="replayForm.target_type" expanded>
<option value="user">{{ valueLabel("subject_type", "user") }}</option>
<option value="subuser">{{ valueLabel("subject_type", "subuser") }}</option>
<option value="customer">{{ valueLabel("subject_type", "customer") }}</option>
<option value="channel">{{ valueLabel("subject_type", "channel") }}</option>
</b-select>
</b-field>
<b-field :label="tr('replay.target')" :message="tr('replay.target_message')">
<b-input v-model="replayForm.target_id" :placeholder="tr('replay.target_placeholder')" />
</b-field>
<b-field :label="tr('replay.channel_scope')" :message="tr('replay.channel_scope_message')">
<b-select v-model.number="replayForm.channel_id" expanded>
<option :value="null">{{ tr("replay.no_channel_scope") }}</option>
<option v-for="channel in channelOptions" :key="channel.id" :value="channel.id">{{ channel.slug }}</option>
</b-select>
</b-field>
<b-field :label="tr('replay.capture_level')">
<b-select v-model="replayForm.capture_level" expanded>
<option value="full_redacted">{{ valueLabel("capture_level", "full_redacted") }}</option>
<option value="metadata">{{ valueLabel("capture_level", "metadata") }}</option>
</b-select>
</b-field>
<b-field :label="tr('replay.expires')" :message="tr('replay.expires_message')">
<b-input v-model="replayForm.expires_at" type="datetime-local" />
</b-field>
<div class="release-form-actions">
<b-button
type="is-dark"
native-type="submit"
icon-left="play"
icon-pack="fas"
:disabled="!props.canReplay"
:loading="busy === 'replay:target'"
>
{{ tr("actions.enable") }}
</b-button>
</div>
</form>
<form class="release-form mt-3" data-testid="release-timeline-filter-form" @submit.prevent="loadReplayData">
<b-field :label="tr('replay.trace_id')" :message="tr('replay.trace_id_message')">
<b-input v-model="timelineFilters.trace_id" :placeholder="tr('replay.trace_id_placeholder')" />
</b-field>
<b-field :label="tr('replay.channel')">
<b-input v-model="timelineFilters.channel_slug" :placeholder="tr('replay.channel_placeholder')" />
</b-field>
<b-field :label="tr('replay.principal_id')">
<b-input v-model="timelineFilters.principal_id" :placeholder="tr('replay.principal_id_placeholder')" />
</b-field>
<b-field :label="trFallback('replay.customer_number', 'Customer number')">
<b-input v-model="timelineFilters.customer_number" placeholder="Customer number" />
</b-field>
<b-field :label="tr('replay.module')">
<BAutocomplete
v-model="timelineFilters.module_key"
:data="moduleOptions"
field="value"
:placeholder="tr('replay.module_placeholder')"
open-on-focus
keep-first
expanded
@select="selectModule"
>
<template #default="slotProps">
<div class="release-autocomplete-option">
<i :class="slotProps.option.icon" aria-hidden="true"></i>
<span>
<strong>{{ slotProps.option.title }}</strong>
<small>{{ slotProps.option.description }}</small>
</span>
</div>
</template>
</BAutocomplete>
</b-field>
<b-field :label="tr('replay.severity')">
<b-select v-model="timelineFilters.severity" expanded>
<option value="">{{ tr("replay.any_severity") }}</option>
<option value="error">{{ valueLabel("severity", "error") }}</option>
<option value="warning">{{ valueLabel("severity", "warning") }}</option>
<option value="info">{{ valueLabel("severity", "info") }}</option>
</b-select>
</b-field>
<b-field :label="trFallback('replay.event_type', 'Event type')">
<b-input v-model="timelineFilters.event_type" placeholder="request_failed" />
</b-field>
<b-field :label="trFallback('replay.device_type', 'Device type')">
<b-select v-model="timelineFilters.device_type" expanded>
<option value="">{{ trFallback("replay.any_device", "Any device") }}</option>
<option value="desktop">desktop</option>
<option value="tablet">tablet</option>
<option value="mobile">mobile</option>
</b-select>
</b-field>
<b-field :label="trFallback('replay.frontend_release', 'Frontend release')">
<b-input v-model="timelineFilters.frontend_version" placeholder="frontend-2026.05.19" />
</b-field>
<b-field :label="trFallback('replay.api_release', 'API release')">
<b-input v-model="timelineFilters.api_version" placeholder="api-2026.05.19" />
</b-field>
<b-field :label="trFallback('replay.date_from', 'From')">
<b-input v-model="timelineFilters.date_from" type="datetime-local" />
</b-field>
<b-field :label="trFallback('replay.date_to', 'To')">
<b-input v-model="timelineFilters.date_to" type="datetime-local" />
</b-field>
<b-field :label="trFallback('replay.error_reports', 'Error reports')">
<b-checkbox v-model="timelineFilters.has_error_report">
{{ trFallback("replay.has_error_report", "Has error report") }}
</b-checkbox>
</b-field>
<div class="release-form-actions">
<b-button
native-type="submit"
icon-left="search"
icon-pack="fas"
:disabled="!props.canReplay"
:loading="busy === 'timeline:search'"
>
{{ tr("actions.search") }}
</b-button>
</div>
</form>
<div class="table-container mt-3">
<table class="table is-fullwidth is-hoverable release-session-table" data-testid="release-timeline-sessions">
<thead>
<tr>
<th>{{ tr("replay.trace_id") }}</th>
<th>{{ trFallback("replay.user", "User") }}</th>
<th>{{ trFallback("replay.device", "Device") }}</th>
<th>{{ tr("replay.channel") }}</th>
<th>{{ trFallback("replay.release", "Release") }}</th>
<th>{{ trFallback("replay.events", "Events") }}</th>
<th>{{ trFallback("replay.last_seen", "Last seen") }}</th>
<th>{{ trFallback("actions.inspect", "Inspect") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="session in sessionRows" :key="session.trace_id">
<td><code>{{ shortValue(session.trace_id) }}</code></td>
<td>{{ userLabel(session) }}</td>
<td>{{ deviceLabel(session) }}</td>
<td>{{ session.channel_slug || "--" }}</td>
<td>{{ releaseLabel(session, "frontend") }} / {{ releaseLabel(session, "api") }}</td>
<td>
{{ session.event_count || 0 }}
<b-tag v-if="session.error_count" type="is-danger" size="is-small">{{ session.error_count }}</b-tag>
<b-tag v-if="session.error_report_count" type="is-warning" size="is-small">
{{ session.error_report_count }}
</b-tag>
</td>
<td>{{ formatDate(session.last_event_at || session.last_seen_at) }}</td>
<td class="release-action-cell">
<ActionSettingsWheelButton
class="release-session-actions"
:data-testid="`release-session-actions-${session.trace_id}`"
>
<template #actions>
<ActionSettingsWheelItem
icon="fas fa-search"
:label="trFallback('actions.inspect', 'Inspect')"
:click-action="() => inspectSession(session)"
:disabled="busy === `session:${session.trace_id}`"
/>
</template>
</ActionSettingsWheelButton>
</td>
</tr>
<tr v-if="sessionRows.length === 0">
<td colspan="8">{{ tr("replay.no_timeline_events") }}</td>
</tr>
</tbody>
</table>
</div>
<Teleport to="body">
<div
v-if="selectedDetail"
class="modal is-active release-inspector-modal"
data-testid="release-timeline-session-modal"
>
<div class="modal-background release-inspector-modal__backdrop" @click="closeDetailModal"></div>
<section class="release-inspector-modal__card" data-testid="release-timeline-session-detail">
<header class="release-inspector-modal__header">
<div>
<p class="release-inspector-modal__eyebrow">{{ trFallback("replay.debug_inspection", "Debug inspection") }}</p>
<h3>{{ userLabel(selectedSession) }}</h3>
<p class="release-inspector-modal__trace"><code>{{ selectedSession.trace_id }}</code></p>
</div>
<div class="release-inspector-modal__meta">
<b-tag :class="statusClass(selectedSession.error_count ? 'error' : 'info')">
{{ selectedSession.error_count ? "errors" : "info" }}
</b-tag>
<button class="delete" type="button" aria-label="close" @click="closeDetailModal"></button>
</div>
</header>
<div class="release-inspector-modal__summary">
<span>{{ selectedSession.channel_slug || "--" }}</span>
<span>{{ deviceLabel(selectedSession) }}</span>
<span>{{ releaseLabel(selectedSession, "frontend") }} / {{ releaseLabel(selectedSession, "api") }}</span>
<span>{{ selectedSession.event_count || 0 }} {{ trFallback("replay.events", "Events") }}</span>
</div>
<div class="release-inspector-modal__tabs" role="tablist">
<button class="button is-small" :class="{ 'is-dark': selectedDetailTab === 'events' }" type="button" @click="selectedDetailTab = 'events'">
{{ trFallback("replay.events", "Events") }}
</button>
<button class="button is-small" :class="{ 'is-dark': selectedDetailTab === 'release' }" type="button" @click="selectedDetailTab = 'release'">
{{ trFallback("replay.release", "Release") }}
</button>
<button class="button is-small" :class="{ 'is-dark': selectedDetailTab === 'device' }" type="button" @click="selectedDetailTab = 'device'">
{{ trFallback("replay.device", "Device") }}
</button>
<button class="button is-small" :class="{ 'is-dark': selectedDetailTab === 'reports' }" type="button" @click="selectedDetailTab = 'reports'">
{{ trFallback("replay.error_reports", "Error reports") }}
</button>
<button class="button is-small" type="button" disabled>
{{ trFallback("replay.visual_replay", "Visual replay") }}
</button>
</div>
<div class="release-inspector-modal__body">
<div v-if="selectedDetailTab === 'events'" class="release-detail-panel">
<ol class="release-timeline" data-testid="release-timeline-events">
<li v-for="event in detailEvents" :key="event.id">
<time>{{ formatDate(event.occurred_at) }}</time>
<strong>{{ event.event_type }}</strong>
<b-tag class="ml-1" :class="statusClass(event.severity)">{{ valueLabel("severity", event.severity) }}</b-tag>
<div class="release-muted">
{{ event.channel_slug || "--" }} / {{ event.module_key || "--" }} /
{{ event.route_path || event.component || "--" }}
</div>
<details>
<summary>{{ trFallback("replay.payload", "Payload") }}</summary>
<pre>{{ jsonPreview(event.payload) }}</pre>
</details>
</li>
<li v-if="detailEvents.length === 0">{{ tr("replay.no_timeline_events") }}</li>
</ol>
</div>
<div v-else-if="selectedDetailTab === 'release'" class="release-detail-panel release-kv-grid">
<span>Channel</span><strong>{{ selectedSession.channel_slug || "--" }}</strong>
<span>Frontend</span><strong>{{ releaseApp("frontend").version_label || releaseLabel(selectedSession, "frontend") }}</strong>
<span>Frontend commit</span><strong>{{ releaseApp("frontend").commit_sha || "--" }}</strong>
<span>Frontend version ref</span><strong>{{ versionReferenceLabel("frontend") }}</strong>
<span>Frontend deployment</span><strong>{{ deploymentReferenceLabel("frontend") }}</strong>
<span>API</span><strong>{{ releaseApp("api").version_label || releaseLabel(selectedSession, "api") }}</strong>
<span>API commit</span><strong>{{ releaseApp("api").commit_sha || "--" }}</strong>
<span>API version ref</span><strong>{{ versionReferenceLabel("api") }}</strong>
<span>API deployment</span><strong>{{ deploymentReferenceLabel("api") }}</strong>
<span>Bundle</span><strong>{{ bundleReferenceLabel() }}</strong>
<span>Route</span><strong>{{ selectedSession.last_route_path || "--" }}</strong>
</div>
<div v-else-if="selectedDetailTab === 'device'" class="release-detail-panel release-kv-grid">
<span>Type</span><strong>{{ selectedSession.device?.type || "--" }}</strong>
<span>Browser</span><strong>{{ selectedSession.device?.browser_name || "--" }} {{ selectedSession.device?.browser_version || "" }}</strong>
<span>OS</span><strong>{{ selectedSession.device?.os_name || "--" }} {{ selectedSession.device?.os_version || "" }}</strong>
<span>Viewport</span><strong>{{ viewportLabel(selectedSession) }}</strong>
<span>User agent</span><strong class="release-break-word">{{ selectedSession.device?.user_agent || "--" }}</strong>
</div>
<div v-else-if="selectedDetailTab === 'reports'" class="release-detail-panel">
<table class="table is-fullwidth is-hoverable">
<thead>
<tr>
<th>ID</th>
<th>Status</th>
<th>Reporter</th>
<th>Route</th>
<th>Created</th>
</tr>
</thead>
<tbody>
<tr v-for="report in detailErrorReports" :key="report.id">
<td>
<router-link to="/superuser/error-reports">#{{ report.id }}</router-link>
</td>
<td>{{ report.status }}</td>
<td>{{ report.reporter?.name || report.reporter?.email || report.reporter?.type || "--" }}</td>
<td>{{ report.route_path || "--" }}</td>
<td>{{ formatDate(report.created_at) }}</td>
</tr>
<tr v-if="detailErrorReports.length === 0">
<td colspan="5">{{ trFallback("replay.no_error_reports", "No linked error reports.") }}</td>
</tr>
</tbody>
</table>
</div>
<p class="release-muted mt-2">
{{ trFallback("replay.visual_replay_disabled", "Visual replay requires a future visual_redacted capture mode.") }}
</p>
</div>
</section>
</div>
</Teleport>
<ol v-if="!selectedDetail" class="release-timeline" data-testid="release-timeline-events">
<li v-for="event in timelineEvents" :key="event.id">
<time>{{ formatDate(event.occurred_at) }}</time>
<strong>{{ event.event_type }}</strong>
<b-tag class="ml-1" :class="statusClass(event.severity)">{{ valueLabel("severity", event.severity) }}</b-tag>
<div class="release-muted">
{{ event.channel_slug || "--" }} / {{ event.module_key || "--" }} /
{{ event.route_path || event.component || "--" }}
</div>
<details>
<summary>{{ trFallback("replay.payload", "Payload") }}</summary>
<pre>{{ jsonPreview(event.payload) }}</pre>
</details>
</li>
<li v-if="timelineEvents.length === 0">{{ tr("replay.no_timeline_events") }}</li>
</ol>
</div>
</template>
<style scoped>
.release-replay-inspector {
display: flex;
flex-direction: column;
gap: 1rem;
}
.release-session-table td {
vertical-align: middle;
}
.release-action-cell {
text-align: right;
width: 1%;
white-space: nowrap;
}
.release-inspector-modal {
align-items: center;
justify-content: center;
padding: 1rem;
z-index: 90;
}
.release-inspector-modal__backdrop {
background: rgba(15, 23, 42, 0.42);
}
.release-inspector-modal__card {
background: #ffffff;
border: 1px solid #d8dee8;
border-radius: 10px;
box-shadow: 0 28px 90px rgba(15, 23, 42, 0.34);
color: #111827;
display: flex;
flex-direction: column;
max-height: min(86vh, 900px);
overflow: hidden;
position: relative;
width: min(1120px, calc(100vw - 2rem));
z-index: 1;
}
.release-inspector-modal__header {
align-items: flex-start;
background: #ffffff;
border-bottom: 1px solid #eef2f7;
display: flex;
gap: 1rem;
justify-content: space-between;
padding: 1.25rem 1.5rem 1rem;
}
.release-inspector-modal__header h3 {
color: #111827;
font-size: 1.2rem;
font-weight: 700;
line-height: 1.25;
margin: 0;
}
.release-inspector-modal__eyebrow {
color: #6b7280;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0;
margin-bottom: 0.25rem;
text-transform: uppercase;
}
.release-inspector-modal__trace {
margin: 0.4rem 0 0;
}
.release-inspector-modal__meta {
align-items: center;
display: flex;
gap: 0.75rem;
}
.release-inspector-modal__summary {
background: #f8fafc;
border-bottom: 1px solid #eef2f7;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
}
.release-inspector-modal__summary span {
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 999px;
color: #374151;
font-size: 0.78rem;
font-weight: 600;
padding: 0.25rem 0.65rem;
}
.release-inspector-modal__tabs {
background: #ffffff;
border-bottom: 1px solid #eef2f7;
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
padding: 0.9rem 1.5rem;
}
.release-inspector-modal__body {
background: #ffffff;
overflow: auto;
padding: 1.25rem 1.5rem 1.5rem;
}
.release-detail-panel {
background: #ffffff;
border: 1px solid #d8dee8;
border-radius: 6px;
color: #111827;
padding: 1rem;
}
.release-kv-grid {
display: grid;
grid-template-columns: minmax(9rem, 12rem) minmax(0, 1fr);
gap: 0.5rem 1rem;
}
.release-kv-grid span {
color: #6b7280;
}
.release-break-word {
word-break: break-word;
}
.release-timeline pre {
background: #111827;
border-radius: 6px;
color: #f9fafb;
font-size: 0.78rem;
margin-top: 0.5rem;
max-height: 24rem;
overflow: auto;
padding: 0.75rem;
white-space: pre-wrap;
}
@media (max-width: 768px) {
.release-inspector-modal__card {
max-height: 92vh;
width: calc(100vw - 1rem);
}
.release-inspector-modal__header {
flex-direction: column;
padding: 1rem;
}
.release-inspector-modal__summary,
.release-inspector-modal__tabs,
.release-inspector-modal__body {
padding-left: 1rem;
padding-right: 1rem;
}
.release-kv-grid {
grid-template-columns: 1fr;
}
}
</style>
+34 -24
View File
@@ -2441,14 +2441,24 @@
"release_manager": {
"title": "Release Manager",
"subtitle": "Gradvise API- og Front-End-udgivelser",
"channel_names": {
"stable": "Stabil",
"canary": "Canary",
"internal": "Intern"
},
"channel_descriptions": {
"stable": "Standard produktionskanal.",
"canary": "Tidlig produktionsvalideringskanal.",
"internal": "Intern kanal til medarbejdere og superbruger-validering."
},
"control_api": {
"title": "Kontrol-API",
"tooltip": "Release Manager-handlinger sendes til denne API. Brug produktions-API'en, medmindre du tester en staging-backend.",
"example": "Eksempel: https://api.truckwash.io:4433",
"endpoint_label": "API-endpoint",
"endpoint_message": "Skift kun dette, n?r Release Manager-endpoints er tilg?ngelige p? m?l-API'en.",
"endpoint_message": "Skift kun dette, når Release Manager-endpoints er tilgængelige på mål-API'en.",
"endpoint_aria": "Release Manager kontrol-API URL",
"use_tooltip": "Indl?s release-data fra denne API",
"use_tooltip": "Indlæs release-data fra denne API",
"use": "Brug",
"reset_tooltip": "Vend tilbage til standard kontrol-API",
"reset": "Nulstil",
@@ -2457,15 +2467,15 @@
"tabs": {
"overview": {
"label": "Oversigt",
"description": "Kanalstatus, ops?tningsfremdrift og seneste release-tilstand."
"description": "Kanalstatus, opsætningsfremdrift og seneste release-tilstand."
},
"channels": {
"label": "Kanaler",
"description": "Opret stabile, canary- og m?lrettede kanaler med rollout-gr?nser."
"description": "Opret stabile, canary- og målrettede kanaler med rollout-grænser."
},
"assignments": {
"label": "Tildelinger",
"description": "Fastg?r brugere, subbrugere eller kunder til en bestemt release-kanal."
"description": "Fastgør brugere, subbrugere eller kunder til en bestemt release-kanal."
},
"deployments": {
"label": "Udrulninger",
@@ -2473,7 +2483,7 @@
},
"replay": {
"label": "Replay",
"description": "Aktiv?r m?lrettet opsamling og s?g i release-tidslinjeh?ndelser."
"description": "Aktivér målrettet opsamling og søg i release-tidslinjehændelser."
},
"integrations": {
"label": "Integrationer",
@@ -2486,22 +2496,22 @@
},
"setup": {
"channels": "Kanaler",
"targets": "GitHub- og Coolify-m?l",
"targets": "GitHub- og Coolify-mål",
"assignments": "Pilot-tildelinger",
"deployments": "F?rste udrulning",
"deployments": "Første udrulning",
"replay": "Replay-opsamling"
},
"stats": {
"channels": "Kanaler",
"targets": "M?l",
"targets": "Mål",
"deployments": "Udrulninger",
"timeline_events": "Tidslinjeh?ndelser"
"timeline_events": "Tidslinjehændelser"
},
"overview": {
"title": "Oversigt",
"subtitle": "Aktuel kanalstatus, release-aktivitet og modulstatus.",
"guided_setup": "Guidet ops?tning",
"next_step": "N?ste: {step}",
"guided_setup": "Guidet opsætning",
"next_step": "Næste: {step}",
"ready": "Release Manager er klar til daglig drift.",
"add_suggested_channel": "Tilføj foreslået kanal",
"module_health_empty": "Modul-health snapshots vises, når probes er blevet registreret."
@@ -2515,14 +2525,14 @@
"github_webhook_secret": "GitHub webhook-hemmelighed",
"configured": "Konfigureret",
"not_configured": "Ikke konfigureret",
"loaded_from": "Indl?st fra {variable}",
"loaded_from": "Indlæst fra {variable}",
"set_below": "Angiv {variable} nedenfor",
"private_repositories_prefix": "Private repositories l?ses med serverens milj?variabel",
"private_repositories_prefix": "Private repositories læses med serverens miljøvariabel",
"private_repositories_or": "eller modulets konfigurationsvariabel",
"github_token_message": "Eksempel: github_pat_... med adgang til de private repositories, Release Manager udruller.",
"github_token_placeholder": "Lad feltet v?re tomt for at beholde det eksisterende token",
"github_token_placeholder": "Lad feltet være tomt for at beholde det eksisterende token",
"github_api_url_message": "Konfigureret i ReleaseManager.github_api_url. Brug https://api.github.com medmindre GitHub Enterprise bruges.",
"webhook_secret_message": "Valgfrit; lad feltet v?re tomt for at beholde den eksisterende hemmelighed.",
"webhook_secret_message": "Valgfrit; lad feltet være tomt for at beholde den eksisterende hemmelighed.",
"webhook_secret_placeholder": "Webhook HMAC-hemmelighed",
"save": "Gem indstillinger",
"back_to_integrations": "Tilbage til integrationer",
@@ -2559,19 +2569,19 @@
"kicker": "Release Manager",
"title": "Release-kanalen er ikke klar",
"summary_prefix": "Din konto er tildelt",
"summary_suffix": ", men kanalen mangler den konfiguration, der skal bruges for at indl?se dens release-image.",
"summary_suffix": ", men kanalen mangler den konfiguration, der skal bruges for at indlæse dens release-image.",
"frontend_url": "Frontend-URL",
"api_url": "API-URL",
"checking_again": "Tjekker igen om {seconds}s",
"refresh_error": "Release-status kunne ikke opdateres. Det n?ste automatiske tjek pr?ver igen.",
"ignore": "Ignorer de n?ste 5 minutter",
"refresh_error": "Release-status kunne ikke opdateres. Det næste automatiske tjek prøver igen.",
"ignore": "Ignorer de næste 5 minutter",
"check_again": "Tjek igen",
"logout": "Log ud",
"base_image": "Basis-image"
},
"channel_switched": {
"kicker": "Release Manager",
"title": "Du er nu p? {channel}",
"title": "Du er nu på {channel}",
"summary_prefix": "Din konto er blevet tildelt release-kanalen",
"summary_suffix": "Denne enhed husker, at du har set denne besked.",
"frontend": "Frontend",
@@ -2579,10 +2589,10 @@
"frontend_version": "Frontend-version",
"api_version": "API-version",
"assigned_channel": "Tildelt kanal",
"current_app_image": "Nuv?rende app-image",
"current_api": "Nuv?rende API",
"current_app_image": "Nuværende app-image",
"current_api": "Nuværende API",
"base_image": "Basis-image",
"continue": "Forts?t"
"continue": "Fortsæt"
},
"common": {
"yes": "ja",
@@ -2762,7 +2772,7 @@
"repository_message": "Eksempel: truckwash/front-end-vue",
"repository_placeholder": "ejer/repo",
"branch": "Branch",
"branch_message": "Eksempel: main",
"branch_message": "Eksempel: master",
"branch_placeholder": "branch",
"commit_selection": "Commit-valg",
"commit_selection_message": "Seneste slås op som branchens head med den konfigurerede GitHub-token.",
+32 -22
View File
@@ -2551,27 +2551,37 @@
"release_manager": {
"title": "Release Manager",
"subtitle": "Schrittweise API- und Front-End-Releases",
"channel_names": {
"stable": "Stabil",
"canary": "Canary",
"internal": "Intern"
},
"channel_descriptions": {
"stable": "Standard-Produktionskanal.",
"canary": "Früher Produktionsvalidierungskanal.",
"internal": "Interner Kanal für Mitarbeitende und Superuser-Validierung."
},
"control_api": {
"title": "Kontroll-API",
"tooltip": "Release-Manager-Aktionen werden an diese API gesendet. Verwende die Produktions-Kontroll-API, au?er du testest ein Staging-Backend.",
"tooltip": "Release-Manager-Aktionen werden an diese API gesendet. Verwende die Produktions-Kontroll-API, außer du testest ein Staging-Backend.",
"example": "Beispiel: https://api.truckwash.io:4433",
"endpoint_label": "API-Endpunkt",
"endpoint_message": "?ndere dies nur, wenn Release-Manager-Endpunkte auf der Ziel-API verf?gbar sind.",
"endpoint_message": "Ändere dies nur, wenn Release-Manager-Endpunkte auf der Ziel-API verfügbar sind.",
"endpoint_aria": "Release Manager Kontroll-API-URL",
"use_tooltip": "Release-Daten von dieser API laden",
"use": "Verwenden",
"reset_tooltip": "Zur Standard-Kontroll-API zur?ckkehren",
"reset": "Zur?cksetzen",
"reset_tooltip": "Zur Standard-Kontroll-API zurückkehren",
"reset": "Zurücksetzen",
"known_endpoints": "Bekannte Kontroll-API-Endpunkte"
},
"tabs": {
"overview": {
"label": "?bersicht",
"label": "Übersicht",
"description": "Kanalzustand, Einrichtungsfortschritt und aktueller Release-Status."
},
"channels": {
"label": "Kan?le",
"description": "Stabile, Canary- und Zielkan?le mit Rollout-Grenzen erstellen."
"label": "Kanäle",
"description": "Stabile, Canary- und Zielkanäle mit Rollout-Grenzen erstellen."
},
"assignments": {
"label": "Zuweisungen",
@@ -2595,30 +2605,30 @@
}
},
"setup": {
"channels": "Kan?le",
"channels": "Kanäle",
"targets": "GitHub- und Coolify-Ziele",
"assignments": "Pilot-Zuweisungen",
"deployments": "Erste Bereitstellung",
"replay": "Replay-Erfassung"
},
"stats": {
"channels": "Kan?le",
"channels": "Kanäle",
"targets": "Ziele",
"deployments": "Bereitstellungen",
"timeline_events": "Timeline-Ereignisse"
},
"overview": {
"title": "?bersicht",
"subtitle": "Aktueller Kanalzustand, Release-Aktivit?t und Modulstatus.",
"guided_setup": "Gef?hrte Einrichtung",
"next_step": "N?chster Schritt: {step}",
"ready": "Release Manager ist f?r den t?glichen Betrieb bereit.",
"title": "Übersicht",
"subtitle": "Aktueller Kanalzustand, Release-Aktivität und Modulstatus.",
"guided_setup": "Geführte Einrichtung",
"next_step": "Nächster Schritt: {step}",
"ready": "Release Manager ist für den täglichen Betrieb bereit.",
"add_suggested_channel": "Vorgeschlagenen Kanal hinzufügen",
"module_health_empty": "Modul-Health-Snapshots erscheinen, nachdem Probes aufgezeichnet wurden."
},
"settings": {
"title": "Release-Manager-Einstellungen",
"subtitle": "GitHub-Token f?r private Repositories und Branch-Abfragen konfigurieren.",
"subtitle": "GitHub-Token für private Repositories und Branch-Abfragen konfigurieren.",
"github_token": "GitHub-Token",
"github_api_url": "GitHub-API-URL",
"webhook_secret": "Webhook-Secret",
@@ -2635,7 +2645,7 @@
"webhook_secret_message": "Optional; leer lassen, um das vorhandene Secret zu behalten.",
"webhook_secret_placeholder": "Webhook-HMAC-Secret",
"save": "Einstellungen speichern",
"back_to_integrations": "Zur?ck zu Integrationen",
"back_to_integrations": "Zurück zu Integrationen",
"guide": {
"steps": {
"github_token": "GitHub-Token",
@@ -2672,10 +2682,10 @@
"summary_suffix": ", aber diesem Kanal fehlt die Konfiguration, um sein Release-Image zu laden.",
"frontend_url": "Frontend-URL",
"api_url": "API-URL",
"checking_again": "Erneute Pr?fung in {seconds}s",
"refresh_error": "Release-Status konnte nicht aktualisiert werden. Die n?chste automatische Pr?fung versucht es erneut.",
"ignore": "N?chste 5 Minuten ignorieren",
"check_again": "Erneut pr?fen",
"checking_again": "Erneute Prüfung in {seconds}s",
"refresh_error": "Release-Status konnte nicht aktualisiert werden. Die nächste automatische Prüfung versucht es erneut.",
"ignore": "Nächste 5 Minuten ignorieren",
"check_again": "Erneut prüfen",
"logout": "Abmelden",
"base_image": "Basis-Image"
},
@@ -2683,7 +2693,7 @@
"kicker": "Release Manager",
"title": "Du bist jetzt auf {channel}",
"summary_prefix": "Dein Konto wurde dem Release-Kanal",
"summary_suffix": "zugewiesen. Dieses Ger?t merkt sich, dass du diesen Hinweis gesehen hast.",
"summary_suffix": "zugewiesen. Dieses Gerät merkt sich, dass du diesen Hinweis gesehen hast.",
"frontend": "Frontend",
"api": "API",
"frontend_version": "Frontend-Version",
@@ -2872,7 +2882,7 @@
"repository_message": "Example: truckwash/front-end-vue",
"repository_placeholder": "owner/repo",
"branch": "Branch",
"branch_message": "Example: main",
"branch_message": "Example: master",
"branch_placeholder": "branch",
"commit_selection": "Commit-Auswahl",
"commit_selection_message": "Neueste Version wird mit dem konfigurierten GitHub-Token als Branch-Head aufgelöst.",
+11 -1
View File
@@ -2275,6 +2275,16 @@
"release_manager": {
"title": "Release Manager",
"subtitle": "Gradual API and Front-End releases",
"channel_names": {
"stable": "Stable",
"canary": "Canary",
"internal": "Internal"
},
"channel_descriptions": {
"stable": "Default production channel.",
"canary": "Early production validation channel.",
"internal": "Internal staff and superuser validation channel."
},
"control_api": {
"title": "Control API",
"tooltip": "Release Manager actions are sent to this API. Use the production control API unless testing a staged backend.",
@@ -2596,7 +2606,7 @@
"repository_message": "Example: truckwash/front-end-vue",
"repository_placeholder": "owner/repo",
"branch": "Branch",
"branch_message": "Example: main",
"branch_message": "Example: master",
"branch_placeholder": "branch",
"commit_selection": "Commit selection",
"commit_selection_message": "Latest resolves to the branch head with the configured GitHub token.",
+10
View File
@@ -1500,6 +1500,16 @@
"release_manager": {
"title": "@:{'templates.generated.compat.configuration.release_manager.title'}",
"subtitle": "@:{'templates.generated.compat.configuration.release_manager.subtitle'}",
"channel_names": {
"stable": "@:{'templates.generated.compat.configuration.release_manager.channel_names.stable'}",
"canary": "@:{'templates.generated.compat.configuration.release_manager.channel_names.canary'}",
"internal": "@:{'templates.generated.compat.configuration.release_manager.channel_names.internal'}"
},
"channel_descriptions": {
"stable": "@:{'templates.generated.compat.configuration.release_manager.channel_descriptions.stable'}",
"canary": "@:{'templates.generated.compat.configuration.release_manager.channel_descriptions.canary'}",
"internal": "@:{'templates.generated.compat.configuration.release_manager.channel_descriptions.internal'}"
},
"control_api": {
"title": "@:{'templates.generated.compat.configuration.release_manager.control_api.title'}",
"tooltip": "@:{'templates.generated.compat.configuration.release_manager.control_api.tooltip'}",
+26 -16
View File
@@ -2552,16 +2552,26 @@
"release_manager": {
"title": "Release Manager",
"subtitle": "Gradvise API- og Front-End-utgivelser",
"channel_names": {
"stable": "Stabil",
"canary": "Canary",
"internal": "Intern"
},
"channel_descriptions": {
"stable": "Standard produksjonskanal.",
"canary": "Tidlig produksjonsvalideringskanal.",
"internal": "Intern kanal for ansatte og superbrukervalidering."
},
"control_api": {
"title": "Kontroll-API",
"tooltip": "Release Manager-handlinger sendes til denne API-en. Bruk produksjons-API-en med mindre du tester en staging-backend.",
"example": "Eksempel: https://api.truckwash.io:4433",
"endpoint_label": "API-endepunkt",
"endpoint_message": "Endre dette bare n?r Release Manager-endepunkter er tilgjengelige p? m?l-API-en.",
"endpoint_message": "Endre dette bare når Release Manager-endepunkter er tilgjengelige på mål-API-en.",
"endpoint_aria": "Release Manager kontroll-API URL",
"use_tooltip": "Last release-data fra denne API-en",
"use": "Bruk",
"reset_tooltip": "G? tilbake til standard kontroll-API",
"reset_tooltip": "Gå tilbake til standard kontroll-API",
"reset": "Tilbakestill",
"known_endpoints": "Kjente kontroll-API-endepunkter"
},
@@ -2572,7 +2582,7 @@
},
"channels": {
"label": "Kanaler",
"description": "Opprett stabile, canary- og m?lrettede kanaler med rollout-grenser."
"description": "Opprett stabile, canary- og målrettede kanaler med rollout-grenser."
},
"assignments": {
"label": "Tildelinger",
@@ -2584,7 +2594,7 @@
},
"replay": {
"label": "Replay",
"description": "Aktiver m?lrettet innsamling og s?k i release-tidslinjehendelser."
"description": "Aktiver målrettet innsamling og søk i release-tidslinjehendelser."
},
"integrations": {
"label": "Integrasjoner",
@@ -2597,14 +2607,14 @@
},
"setup": {
"channels": "Kanaler",
"targets": "GitHub- og Coolify-m?l",
"targets": "GitHub- og Coolify-mål",
"assignments": "Pilottildelinger",
"deployments": "F?rste utrulling",
"deployments": "Første utrulling",
"replay": "Replay-innsamling"
},
"stats": {
"channels": "Kanaler",
"targets": "M?l",
"targets": "Mål",
"deployments": "Utrullinger",
"timeline_events": "Tidslinjehendelser"
},
@@ -2628,12 +2638,12 @@
"not_configured": "Ikke konfigurert",
"loaded_from": "Lastet fra {variable}",
"set_below": "Angi {variable} nedenfor",
"private_repositories_prefix": "Private repositories leses med serverens milj?variabel",
"private_repositories_prefix": "Private repositories leses med serverens miljøvariabel",
"private_repositories_or": "eller modulens konfigurasjonsvariabel",
"github_token_message": "Eksempel: github_pat_... med tilgang til de private repositories Release Manager ruller ut.",
"github_token_placeholder": "La st? tomt for ? beholde eksisterende token",
"github_token_placeholder": "La stå tomt for å beholde eksisterende token",
"github_api_url_message": "Konfigurert i ReleaseManager.github_api_url. Bruk https://api.github.com med mindre GitHub Enterprise brukes.",
"webhook_secret_message": "Valgfritt; la st? tomt for ? beholde eksisterende hemmelighet.",
"webhook_secret_message": "Valgfritt; la stå tomt for å beholde eksisterende hemmelighet.",
"webhook_secret_placeholder": "Webhook HMAC-hemmelighet",
"save": "Lagre innstillinger",
"back_to_integrations": "Tilbake til integrasjoner",
@@ -2670,11 +2680,11 @@
"kicker": "Release Manager",
"title": "Release-kanalen er ikke klar",
"summary_prefix": "Kontoen din er tildelt",
"summary_suffix": ", men kanalen mangler konfigurasjonen som trengs for ? laste release-imaget.",
"summary_suffix": ", men kanalen mangler konfigurasjonen som trengs for å laste release-imaget.",
"frontend_url": "Frontend-URL",
"api_url": "API-URL",
"checking_again": "Sjekker igjen om {seconds}s",
"refresh_error": "Release-status kunne ikke oppdateres. Neste automatiske sjekk pr?ver igjen.",
"refresh_error": "Release-status kunne ikke oppdateres. Neste automatiske sjekk prøver igjen.",
"ignore": "Ignorer de neste 5 minuttene",
"check_again": "Sjekk igjen",
"logout": "Logg ut",
@@ -2682,7 +2692,7 @@
},
"channel_switched": {
"kicker": "Release Manager",
"title": "Du er n? p? {channel}",
"title": "Du er nå på {channel}",
"summary_prefix": "Kontoen din er tildelt release-kanalen",
"summary_suffix": "Denne enheten husker at du har sett denne meldingen.",
"frontend": "Frontend",
@@ -2690,8 +2700,8 @@
"frontend_version": "Frontend-versjon",
"api_version": "API-versjon",
"assigned_channel": "Tildelt kanal",
"current_app_image": "N?v?rende app-image",
"current_api": "N?v?rende API",
"current_app_image": "Nåværende app-image",
"current_api": "Nåværende API",
"base_image": "Basis-image",
"continue": "Fortsett"
},
@@ -2873,7 +2883,7 @@
"repository_message": "Eksempel: truckwash/front-end-vue",
"repository_placeholder": "ejer/repo",
"branch": "Branch",
"branch_message": "Eksempel: main",
"branch_message": "Eksempel: master",
"branch_placeholder": "branch",
"commit_selection": "Commit-valg",
"commit_selection_message": "Seneste slås op som branchens head med den konfigurerede GitHub-token.",
+50 -40
View File
@@ -2602,31 +2602,41 @@
"release_manager": {
"title": "Release Manager",
"subtitle": "Gradvisa API- och Front-End-versioner",
"channel_names": {
"stable": "Stabil",
"canary": "Canary",
"internal": "Intern"
},
"channel_descriptions": {
"stable": "Standardkanal för produktion.",
"canary": "Tidig produktionsvalideringskanal.",
"internal": "Intern kanal för personal och superanvändarvalidering."
},
"control_api": {
"title": "Kontroll-API",
"tooltip": "Release Manager-?tg?rder skickas till detta API. Anv?nd produktions-API:t om du inte testar en staging-backend.",
"tooltip": "Release Manager-åtgärder skickas till detta API. Använd produktions-API:t om du inte testar en staging-backend.",
"example": "Exempel: https://api.truckwash.io:4433",
"endpoint_label": "API-endpoint",
"endpoint_message": "?ndra detta endast n?r Release Manager-endpoints finns p? m?l-API:t.",
"endpoint_message": "Ändra detta endast när Release Manager-endpoints finns på mål-API:t.",
"endpoint_aria": "Release Manager kontroll-API URL",
"use_tooltip": "L?s in release-data fr?n detta API",
"use": "Anv?nd",
"reset_tooltip": "G? tillbaka till standard kontroll-API",
"reset": "?terst?ll",
"known_endpoints": "K?nda kontroll-API-endpoints"
"use_tooltip": "Läs in release-data från detta API",
"use": "Använd",
"reset_tooltip": "Gå tillbaka till standard kontroll-API",
"reset": "Återställ",
"known_endpoints": "Kända kontroll-API-endpoints"
},
"tabs": {
"overview": {
"label": "?versikt",
"description": "Kanalh?lsa, installationsstatus och senaste release-l?ge."
"label": "Översikt",
"description": "Kanalhälsa, installationsstatus och senaste release-läge."
},
"channels": {
"label": "Kanaler",
"description": "Skapa stabila, canary- och riktade kanaler med rollout-gr?nser."
"description": "Skapa stabila, canary- och riktade kanaler med rollout-gränser."
},
"assignments": {
"label": "Tilldelningar",
"description": "Koppla anv?ndare, underanv?ndare eller kunder till en specifik release-kanal."
"description": "Koppla användare, underanvändare eller kunder till en specifik release-kanal."
},
"deployments": {
"label": "Utrullningar",
@@ -2634,58 +2644,58 @@
},
"replay": {
"label": "Replay",
"description": "Aktivera riktad insamling och s?k i release-tidslinjeh?ndelser."
"description": "Aktivera riktad insamling och sök i release-tidslinjehändelser."
},
"integrations": {
"label": "Integrationer",
"description": "Anslut GitHub-repositories, branches och Coolify-tj?nster."
"description": "Anslut GitHub-repositories, branches och Coolify-tjänster."
},
"settings": {
"label": "Inst?llningar",
"description": "Konfigurera Release Managers GitHub-?tkomst och webhook-inst?llningar."
"label": "Inställningar",
"description": "Konfigurera Release Managers GitHub-åtkomst och webhook-inställningar."
}
},
"setup": {
"channels": "Kanaler",
"targets": "GitHub- och Coolify-m?l",
"targets": "GitHub- och Coolify-mål",
"assignments": "Pilottilldelningar",
"deployments": "F?rsta utrullningen",
"deployments": "Första utrullningen",
"replay": "Replay-insamling"
},
"stats": {
"channels": "Kanaler",
"targets": "M?l",
"targets": "Mål",
"deployments": "Utrullningar",
"timeline_events": "Tidslinjeh?ndelser"
"timeline_events": "Tidslinjehändelser"
},
"overview": {
"title": "?versikt",
"subtitle": "Aktuell kanalh?lsa, release-aktivitet och modulstatus.",
"title": "Översikt",
"subtitle": "Aktuell kanalhälsa, release-aktivitet och modulstatus.",
"guided_setup": "Guidad installation",
"next_step": "N?sta: {step}",
"ready": "Release Manager ?r klar f?r daglig drift.",
"next_step": "Nästa: {step}",
"ready": "Release Manager är klar för daglig drift.",
"add_suggested_channel": "Lägg till föreslagen kanal",
"module_health_empty": "Modulhälsosnapshots visas när probes har registrerats."
},
"settings": {
"title": "Release Manager-inst?llningar",
"subtitle": "Konfigurera GitHub-token som anv?nds f?r privata repositories och branch-uppslag.",
"title": "Release Manager-inställningar",
"subtitle": "Konfigurera GitHub-token som används för privata repositories och branch-uppslag.",
"github_token": "GitHub-token",
"github_api_url": "GitHub API-URL",
"webhook_secret": "Webhook-hemlighet",
"github_webhook_secret": "GitHub webhook-hemlighet",
"configured": "Konfigurerad",
"not_configured": "Inte konfigurerad",
"loaded_from": "Inl?st fr?n {variable}",
"loaded_from": "Inläst från {variable}",
"set_below": "Ange {variable} nedan",
"private_repositories_prefix": "Privata repositories l?ses med serverns milj?variabel",
"private_repositories_prefix": "Privata repositories läses med serverns miljövariabel",
"private_repositories_or": "eller modulens konfigurationsvariabel",
"github_token_message": "Exempel: github_pat_... med ?tkomst till de privata repositories som Release Manager distribuerar.",
"github_token_placeholder": "L?mna tomt f?r att beh?lla befintlig token",
"github_api_url_message": "Konfigurerad i ReleaseManager.github_api_url. Anv?nd https://api.github.com om du inte anv?nder GitHub Enterprise.",
"webhook_secret_message": "Valfritt; l?mna tomt f?r att beh?lla befintlig hemlighet.",
"github_token_message": "Exempel: github_pat_... med åtkomst till de privata repositories som Release Manager distribuerar.",
"github_token_placeholder": "Lämna tomt för att behålla befintlig token",
"github_api_url_message": "Konfigurerad i ReleaseManager.github_api_url. Använd https://api.github.com om du inte använder GitHub Enterprise.",
"webhook_secret_message": "Valfritt; lämna tomt för att behålla befintlig hemlighet.",
"webhook_secret_placeholder": "Webhook HMAC-hemlighet",
"save": "Spara inst?llningar",
"save": "Spara inställningar",
"back_to_integrations": "Tillbaka till integrationer",
"guide": {
"steps": {
@@ -2718,13 +2728,13 @@
},
"channel_unavailable": {
"kicker": "Release Manager",
"title": "Release-kanalen ?r inte klar",
"summary_prefix": "Ditt konto ?r tilldelat",
"summary_suffix": ", men kanalen saknar konfigurationen som beh?vs f?r att l?sa in dess release-image.",
"title": "Release-kanalen är inte klar",
"summary_prefix": "Ditt konto är tilldelat",
"summary_suffix": ", men kanalen saknar konfigurationen som behövs för att läsa in dess release-image.",
"frontend_url": "Frontend-URL",
"api_url": "API-URL",
"checking_again": "Kontrollerar igen om {seconds}s",
"refresh_error": "Release-status kunde inte uppdateras. N?sta automatiska kontroll f?rs?ker igen.",
"refresh_error": "Release-status kunde inte uppdateras. Nästa automatiska kontroll försöker igen.",
"ignore": "Ignorera de kommande 5 minuterna",
"check_again": "Kontrollera igen",
"logout": "Logga ut",
@@ -2732,9 +2742,9 @@
},
"channel_switched": {
"kicker": "Release Manager",
"title": "Du ?r nu p? {channel}",
"title": "Du är nu på {channel}",
"summary_prefix": "Ditt konto har tilldelats release-kanalen",
"summary_suffix": "Den h?r enheten kommer ih?g att du har sett detta meddelande.",
"summary_suffix": "Den här enheten kommer ihåg att du har sett detta meddelande.",
"frontend": "Frontend",
"api": "API",
"frontend_version": "Frontend-version",
@@ -2743,7 +2753,7 @@
"current_app_image": "Nuvarande app-image",
"current_api": "Nuvarande API",
"base_image": "Bas-image",
"continue": "Forts?tt"
"continue": "Fortsätt"
},
"common": {
"yes": "ja",
@@ -2923,7 +2933,7 @@
"repository_message": "Eksempel: truckwash/front-end-vue",
"repository_placeholder": "ejer/repo",
"branch": "Branch",
"branch_message": "Eksempel: main",
"branch_message": "Eksempel: master",
"branch_placeholder": "branch",
"commit_selection": "Commit-val",
"commit_selection_message": "Senaste slås upp som branchens head med konfigurerad GitHub-token.",
+34 -24
View File
@@ -1499,14 +1499,24 @@
"release_manager": {
"title": "Release Manager",
"subtitle": "Gradvise API- og Front-End-udgivelser",
"channel_names": {
"stable": "Stabil",
"canary": "Canary",
"internal": "Intern"
},
"channel_descriptions": {
"stable": "Standard produktionskanal.",
"canary": "Tidlig produktionsvalideringskanal.",
"internal": "Intern kanal til medarbejdere og superbruger-validering."
},
"control_api": {
"title": "Kontrol-API",
"tooltip": "Release Manager-handlinger sendes til denne API. Brug produktions-API'en, medmindre du tester en staging-backend.",
"example": "Eksempel: https://api.truckwash.io:4433",
"endpoint_label": "API-endpoint",
"endpoint_message": "Skift kun dette, n?r Release Manager-endpoints er tilg?ngelige p? m?l-API'en.",
"endpoint_message": "Skift kun dette, når Release Manager-endpoints er tilgængelige på mål-API'en.",
"endpoint_aria": "Release Manager kontrol-API URL",
"use_tooltip": "Indl?s release-data fra denne API",
"use_tooltip": "Indlæs release-data fra denne API",
"use": "Brug",
"reset_tooltip": "Vend tilbage til standard kontrol-API",
"reset": "Nulstil",
@@ -1515,15 +1525,15 @@
"tabs": {
"overview": {
"label": "Oversigt",
"description": "Kanalstatus, ops?tningsfremdrift og seneste release-tilstand."
"description": "Kanalstatus, opsætningsfremdrift og seneste release-tilstand."
},
"channels": {
"label": "Kanaler",
"description": "Opret stabile, canary- og m?lrettede kanaler med rollout-gr?nser."
"description": "Opret stabile, canary- og målrettede kanaler med rollout-grænser."
},
"assignments": {
"label": "Tildelinger",
"description": "Fastg?r brugere, subbrugere eller kunder til en bestemt release-kanal."
"description": "Fastgør brugere, subbrugere eller kunder til en bestemt release-kanal."
},
"deployments": {
"label": "Udrulninger",
@@ -1531,7 +1541,7 @@
},
"replay": {
"label": "Replay",
"description": "Aktiv?r m?lrettet opsamling og s?g i release-tidslinjeh?ndelser."
"description": "Aktivér målrettet opsamling og søg i release-tidslinjehændelser."
},
"integrations": {
"label": "Integrationer",
@@ -1544,22 +1554,22 @@
},
"setup": {
"channels": "Kanaler",
"targets": "GitHub- og Coolify-m?l",
"targets": "GitHub- og Coolify-mål",
"assignments": "Pilot-tildelinger",
"deployments": "F?rste udrulning",
"deployments": "Første udrulning",
"replay": "Replay-opsamling"
},
"stats": {
"channels": "Kanaler",
"targets": "M?l",
"targets": "Mål",
"deployments": "Udrulninger",
"timeline_events": "Tidslinjeh?ndelser"
"timeline_events": "Tidslinjehændelser"
},
"overview": {
"title": "Oversigt",
"subtitle": "Aktuel kanalstatus, release-aktivitet og modulstatus.",
"guided_setup": "Guidet ops?tning",
"next_step": "N?ste: {step}",
"guided_setup": "Guidet opsætning",
"next_step": "Næste: {step}",
"ready": "Release Manager er klar til daglig drift.",
"add_suggested_channel": "Tilføj foreslået kanal",
"module_health_empty": "Modul-health snapshots vises, når probes er blevet registreret."
@@ -1573,14 +1583,14 @@
"github_webhook_secret": "GitHub webhook-hemmelighed",
"configured": "Konfigureret",
"not_configured": "Ikke konfigureret",
"loaded_from": "Indl?st fra {variable}",
"loaded_from": "Indlæst fra {variable}",
"set_below": "Angiv {variable} nedenfor",
"private_repositories_prefix": "Private repositories l?ses med serverens milj?variabel",
"private_repositories_prefix": "Private repositories læses med serverens miljøvariabel",
"private_repositories_or": "eller modulets konfigurationsvariabel",
"github_token_message": "Eksempel: github_pat_... med adgang til de private repositories, Release Manager udruller.",
"github_token_placeholder": "Lad feltet v?re tomt for at beholde det eksisterende token",
"github_token_placeholder": "Lad feltet være tomt for at beholde det eksisterende token",
"github_api_url_message": "Konfigureret i ReleaseManager.github_api_url. Brug https://api.github.com medmindre GitHub Enterprise bruges.",
"webhook_secret_message": "Valgfrit; lad feltet v?re tomt for at beholde den eksisterende hemmelighed.",
"webhook_secret_message": "Valgfrit; lad feltet være tomt for at beholde den eksisterende hemmelighed.",
"webhook_secret_placeholder": "Webhook HMAC-hemmelighed",
"save": "Gem indstillinger",
"back_to_integrations": "Tilbage til integrationer",
@@ -1617,19 +1627,19 @@
"kicker": "Release Manager",
"title": "Release-kanalen er ikke klar",
"summary_prefix": "Din konto er tildelt",
"summary_suffix": ", men kanalen mangler den konfiguration, der skal bruges for at indl?se dens release-image.",
"summary_suffix": ", men kanalen mangler den konfiguration, der skal bruges for at indlæse dens release-image.",
"frontend_url": "Frontend-URL",
"api_url": "API-URL",
"checking_again": "Tjekker igen om {seconds}s",
"refresh_error": "Release-status kunne ikke opdateres. Det n?ste automatiske tjek pr?ver igen.",
"ignore": "Ignorer de n?ste 5 minutter",
"refresh_error": "Release-status kunne ikke opdateres. Det næste automatiske tjek prøver igen.",
"ignore": "Ignorer de næste 5 minutter",
"check_again": "Tjek igen",
"logout": "Log ud",
"base_image": "Basis-image"
},
"channel_switched": {
"kicker": "Release Manager",
"title": "Du er nu p? {channel}",
"title": "Du er nu på {channel}",
"summary_prefix": "Din konto er blevet tildelt release-kanalen",
"summary_suffix": "Denne enhed husker, at du har set denne besked.",
"frontend": "Frontend",
@@ -1637,10 +1647,10 @@
"frontend_version": "Frontend-version",
"api_version": "API-version",
"assigned_channel": "Tildelt kanal",
"current_app_image": "Nuv?rende app-image",
"current_api": "Nuv?rende API",
"current_app_image": "Nuværende app-image",
"current_api": "Nuværende API",
"base_image": "Basis-image",
"continue": "Forts?t"
"continue": "Fortsæt"
},
"common": {
"yes": "ja",
@@ -1820,7 +1830,7 @@
"repository_message": "Eksempel: truckwash/front-end-vue",
"repository_placeholder": "ejer/repo",
"branch": "Branch",
"branch_message": "Eksempel: main",
"branch_message": "Eksempel: master",
"branch_placeholder": "branch",
"commit_selection": "Commit-valg",
"commit_selection_message": "Seneste slås op som branchens head med den konfigurerede GitHub-token.",
+32 -22
View File
@@ -1499,27 +1499,37 @@
"release_manager": {
"title": "Release Manager",
"subtitle": "Schrittweise API- und Front-End-Releases",
"channel_names": {
"stable": "Stabil",
"canary": "Canary",
"internal": "Intern"
},
"channel_descriptions": {
"stable": "Standard-Produktionskanal.",
"canary": "Früher Produktionsvalidierungskanal.",
"internal": "Interner Kanal für Mitarbeitende und Superuser-Validierung."
},
"control_api": {
"title": "Kontroll-API",
"tooltip": "Release-Manager-Aktionen werden an diese API gesendet. Verwende die Produktions-Kontroll-API, au?er du testest ein Staging-Backend.",
"tooltip": "Release-Manager-Aktionen werden an diese API gesendet. Verwende die Produktions-Kontroll-API, außer du testest ein Staging-Backend.",
"example": "Beispiel: https://api.truckwash.io:4433",
"endpoint_label": "API-Endpunkt",
"endpoint_message": "?ndere dies nur, wenn Release-Manager-Endpunkte auf der Ziel-API verf?gbar sind.",
"endpoint_message": "Ändere dies nur, wenn Release-Manager-Endpunkte auf der Ziel-API verfügbar sind.",
"endpoint_aria": "Release Manager Kontroll-API-URL",
"use_tooltip": "Release-Daten von dieser API laden",
"use": "Verwenden",
"reset_tooltip": "Zur Standard-Kontroll-API zur?ckkehren",
"reset": "Zur?cksetzen",
"reset_tooltip": "Zur Standard-Kontroll-API zurückkehren",
"reset": "Zurücksetzen",
"known_endpoints": "Bekannte Kontroll-API-Endpunkte"
},
"tabs": {
"overview": {
"label": "?bersicht",
"label": "Übersicht",
"description": "Kanalzustand, Einrichtungsfortschritt und aktueller Release-Status."
},
"channels": {
"label": "Kan?le",
"description": "Stabile, Canary- und Zielkan?le mit Rollout-Grenzen erstellen."
"label": "Kanäle",
"description": "Stabile, Canary- und Zielkanäle mit Rollout-Grenzen erstellen."
},
"assignments": {
"label": "Zuweisungen",
@@ -1543,30 +1553,30 @@
}
},
"setup": {
"channels": "Kan?le",
"channels": "Kanäle",
"targets": "GitHub- und Coolify-Ziele",
"assignments": "Pilot-Zuweisungen",
"deployments": "Erste Bereitstellung",
"replay": "Replay-Erfassung"
},
"stats": {
"channels": "Kan?le",
"channels": "Kanäle",
"targets": "Ziele",
"deployments": "Bereitstellungen",
"timeline_events": "Timeline-Ereignisse"
},
"overview": {
"title": "?bersicht",
"subtitle": "Aktueller Kanalzustand, Release-Aktivit?t und Modulstatus.",
"guided_setup": "Gef?hrte Einrichtung",
"next_step": "N?chster Schritt: {step}",
"ready": "Release Manager ist f?r den t?glichen Betrieb bereit.",
"title": "Übersicht",
"subtitle": "Aktueller Kanalzustand, Release-Aktivität und Modulstatus.",
"guided_setup": "Geführte Einrichtung",
"next_step": "Nächster Schritt: {step}",
"ready": "Release Manager ist für den täglichen Betrieb bereit.",
"add_suggested_channel": "Vorgeschlagenen Kanal hinzufügen",
"module_health_empty": "Modul-Health-Snapshots erscheinen, nachdem Probes aufgezeichnet wurden."
},
"settings": {
"title": "Release-Manager-Einstellungen",
"subtitle": "GitHub-Token f?r private Repositories und Branch-Abfragen konfigurieren.",
"subtitle": "GitHub-Token für private Repositories und Branch-Abfragen konfigurieren.",
"github_token": "GitHub-Token",
"github_api_url": "GitHub-API-URL",
"webhook_secret": "Webhook-Secret",
@@ -1583,7 +1593,7 @@
"webhook_secret_message": "Optional; leer lassen, um das vorhandene Secret zu behalten.",
"webhook_secret_placeholder": "Webhook-HMAC-Secret",
"save": "Einstellungen speichern",
"back_to_integrations": "Zur?ck zu Integrationen",
"back_to_integrations": "Zurück zu Integrationen",
"guide": {
"steps": {
"github_token": "GitHub-Token",
@@ -1620,10 +1630,10 @@
"summary_suffix": ", aber diesem Kanal fehlt die Konfiguration, um sein Release-Image zu laden.",
"frontend_url": "Frontend-URL",
"api_url": "API-URL",
"checking_again": "Erneute Pr?fung in {seconds}s",
"refresh_error": "Release-Status konnte nicht aktualisiert werden. Die n?chste automatische Pr?fung versucht es erneut.",
"ignore": "N?chste 5 Minuten ignorieren",
"check_again": "Erneut pr?fen",
"checking_again": "Erneute Prüfung in {seconds}s",
"refresh_error": "Release-Status konnte nicht aktualisiert werden. Die nächste automatische Prüfung versucht es erneut.",
"ignore": "Nächste 5 Minuten ignorieren",
"check_again": "Erneut prüfen",
"logout": "Abmelden",
"base_image": "Basis-Image"
},
@@ -1631,7 +1641,7 @@
"kicker": "Release Manager",
"title": "Du bist jetzt auf {channel}",
"summary_prefix": "Dein Konto wurde dem Release-Kanal",
"summary_suffix": "zugewiesen. Dieses Ger?t merkt sich, dass du diesen Hinweis gesehen hast.",
"summary_suffix": "zugewiesen. Dieses Gerät merkt sich, dass du diesen Hinweis gesehen hast.",
"frontend": "Frontend",
"api": "API",
"frontend_version": "Frontend-Version",
@@ -1820,7 +1830,7 @@
"repository_message": "Example: truckwash/front-end-vue",
"repository_placeholder": "owner/repo",
"branch": "Branch",
"branch_message": "Example: main",
"branch_message": "Example: master",
"branch_placeholder": "branch",
"commit_selection": "Commit-Auswahl",
"commit_selection_message": "Neueste Version wird mit dem konfigurierten GitHub-Token als Branch-Head aufgelöst.",
+11 -1
View File
@@ -1499,6 +1499,16 @@
"release_manager": {
"title": "Release Manager",
"subtitle": "Gradual API and Front-End releases",
"channel_names": {
"stable": "Stable",
"canary": "Canary",
"internal": "Internal"
},
"channel_descriptions": {
"stable": "Default production channel.",
"canary": "Early production validation channel.",
"internal": "Internal staff and superuser validation channel."
},
"control_api": {
"title": "Control API",
"tooltip": "Release Manager actions are sent to this API. Use the production control API unless testing a staged backend.",
@@ -1820,7 +1830,7 @@
"repository_message": "Example: truckwash/front-end-vue",
"repository_placeholder": "owner/repo",
"branch": "Branch",
"branch_message": "Example: main",
"branch_message": "Example: master",
"branch_placeholder": "branch",
"commit_selection": "Commit selection",
"commit_selection_message": "Latest resolves to the branch head with the configured GitHub token.",
+26 -16
View File
@@ -1499,16 +1499,26 @@
"release_manager": {
"title": "Release Manager",
"subtitle": "Gradvise API- og Front-End-utgivelser",
"channel_names": {
"stable": "Stabil",
"canary": "Canary",
"internal": "Intern"
},
"channel_descriptions": {
"stable": "Standard produksjonskanal.",
"canary": "Tidlig produksjonsvalideringskanal.",
"internal": "Intern kanal for ansatte og superbrukervalidering."
},
"control_api": {
"title": "Kontroll-API",
"tooltip": "Release Manager-handlinger sendes til denne API-en. Bruk produksjons-API-en med mindre du tester en staging-backend.",
"example": "Eksempel: https://api.truckwash.io:4433",
"endpoint_label": "API-endepunkt",
"endpoint_message": "Endre dette bare n?r Release Manager-endepunkter er tilgjengelige p? m?l-API-en.",
"endpoint_message": "Endre dette bare når Release Manager-endepunkter er tilgjengelige på mål-API-en.",
"endpoint_aria": "Release Manager kontroll-API URL",
"use_tooltip": "Last release-data fra denne API-en",
"use": "Bruk",
"reset_tooltip": "G? tilbake til standard kontroll-API",
"reset_tooltip": "Gå tilbake til standard kontroll-API",
"reset": "Tilbakestill",
"known_endpoints": "Kjente kontroll-API-endepunkter"
},
@@ -1519,7 +1529,7 @@
},
"channels": {
"label": "Kanaler",
"description": "Opprett stabile, canary- og m?lrettede kanaler med rollout-grenser."
"description": "Opprett stabile, canary- og målrettede kanaler med rollout-grenser."
},
"assignments": {
"label": "Tildelinger",
@@ -1531,7 +1541,7 @@
},
"replay": {
"label": "Replay",
"description": "Aktiver m?lrettet innsamling og s?k i release-tidslinjehendelser."
"description": "Aktiver målrettet innsamling og søk i release-tidslinjehendelser."
},
"integrations": {
"label": "Integrasjoner",
@@ -1544,14 +1554,14 @@
},
"setup": {
"channels": "Kanaler",
"targets": "GitHub- og Coolify-m?l",
"targets": "GitHub- og Coolify-mål",
"assignments": "Pilottildelinger",
"deployments": "F?rste utrulling",
"deployments": "Første utrulling",
"replay": "Replay-innsamling"
},
"stats": {
"channels": "Kanaler",
"targets": "M?l",
"targets": "Mål",
"deployments": "Utrullinger",
"timeline_events": "Tidslinjehendelser"
},
@@ -1575,12 +1585,12 @@
"not_configured": "Ikke konfigurert",
"loaded_from": "Lastet fra {variable}",
"set_below": "Angi {variable} nedenfor",
"private_repositories_prefix": "Private repositories leses med serverens milj?variabel",
"private_repositories_prefix": "Private repositories leses med serverens miljøvariabel",
"private_repositories_or": "eller modulens konfigurasjonsvariabel",
"github_token_message": "Eksempel: github_pat_... med tilgang til de private repositories Release Manager ruller ut.",
"github_token_placeholder": "La st? tomt for ? beholde eksisterende token",
"github_token_placeholder": "La stå tomt for å beholde eksisterende token",
"github_api_url_message": "Konfigurert i ReleaseManager.github_api_url. Bruk https://api.github.com med mindre GitHub Enterprise brukes.",
"webhook_secret_message": "Valgfritt; la st? tomt for ? beholde eksisterende hemmelighet.",
"webhook_secret_message": "Valgfritt; la stå tomt for å beholde eksisterende hemmelighet.",
"webhook_secret_placeholder": "Webhook HMAC-hemmelighet",
"save": "Lagre innstillinger",
"back_to_integrations": "Tilbake til integrasjoner",
@@ -1617,11 +1627,11 @@
"kicker": "Release Manager",
"title": "Release-kanalen er ikke klar",
"summary_prefix": "Kontoen din er tildelt",
"summary_suffix": ", men kanalen mangler konfigurasjonen som trengs for ? laste release-imaget.",
"summary_suffix": ", men kanalen mangler konfigurasjonen som trengs for å laste release-imaget.",
"frontend_url": "Frontend-URL",
"api_url": "API-URL",
"checking_again": "Sjekker igjen om {seconds}s",
"refresh_error": "Release-status kunne ikke oppdateres. Neste automatiske sjekk pr?ver igjen.",
"refresh_error": "Release-status kunne ikke oppdateres. Neste automatiske sjekk prøver igjen.",
"ignore": "Ignorer de neste 5 minuttene",
"check_again": "Sjekk igjen",
"logout": "Logg ut",
@@ -1629,7 +1639,7 @@
},
"channel_switched": {
"kicker": "Release Manager",
"title": "Du er n? p? {channel}",
"title": "Du er nå på {channel}",
"summary_prefix": "Kontoen din er tildelt release-kanalen",
"summary_suffix": "Denne enheten husker at du har sett denne meldingen.",
"frontend": "Frontend",
@@ -1637,8 +1647,8 @@
"frontend_version": "Frontend-versjon",
"api_version": "API-versjon",
"assigned_channel": "Tildelt kanal",
"current_app_image": "N?v?rende app-image",
"current_api": "N?v?rende API",
"current_app_image": "Nåværende app-image",
"current_api": "Nåværende API",
"base_image": "Basis-image",
"continue": "Fortsett"
},
@@ -1820,7 +1830,7 @@
"repository_message": "Eksempel: truckwash/front-end-vue",
"repository_placeholder": "ejer/repo",
"branch": "Branch",
"branch_message": "Eksempel: main",
"branch_message": "Eksempel: master",
"branch_placeholder": "branch",
"commit_selection": "Commit-valg",
"commit_selection_message": "Seneste slås op som branchens head med den konfigurerede GitHub-token.",
+50 -40
View File
@@ -1499,31 +1499,41 @@
"release_manager": {
"title": "Release Manager",
"subtitle": "Gradvisa API- och Front-End-versioner",
"channel_names": {
"stable": "Stabil",
"canary": "Canary",
"internal": "Intern"
},
"channel_descriptions": {
"stable": "Standardkanal för produktion.",
"canary": "Tidig produktionsvalideringskanal.",
"internal": "Intern kanal för personal och superanvändarvalidering."
},
"control_api": {
"title": "Kontroll-API",
"tooltip": "Release Manager-?tg?rder skickas till detta API. Anv?nd produktions-API:t om du inte testar en staging-backend.",
"tooltip": "Release Manager-åtgärder skickas till detta API. Använd produktions-API:t om du inte testar en staging-backend.",
"example": "Exempel: https://api.truckwash.io:4433",
"endpoint_label": "API-endpoint",
"endpoint_message": "?ndra detta endast n?r Release Manager-endpoints finns p? m?l-API:t.",
"endpoint_message": "Ändra detta endast när Release Manager-endpoints finns på mål-API:t.",
"endpoint_aria": "Release Manager kontroll-API URL",
"use_tooltip": "L?s in release-data fr?n detta API",
"use": "Anv?nd",
"reset_tooltip": "G? tillbaka till standard kontroll-API",
"reset": "?terst?ll",
"known_endpoints": "K?nda kontroll-API-endpoints"
"use_tooltip": "Läs in release-data från detta API",
"use": "Använd",
"reset_tooltip": "Gå tillbaka till standard kontroll-API",
"reset": "Återställ",
"known_endpoints": "Kända kontroll-API-endpoints"
},
"tabs": {
"overview": {
"label": "?versikt",
"description": "Kanalh?lsa, installationsstatus och senaste release-l?ge."
"label": "Översikt",
"description": "Kanalhälsa, installationsstatus och senaste release-läge."
},
"channels": {
"label": "Kanaler",
"description": "Skapa stabila, canary- och riktade kanaler med rollout-gr?nser."
"description": "Skapa stabila, canary- och riktade kanaler med rollout-gränser."
},
"assignments": {
"label": "Tilldelningar",
"description": "Koppla anv?ndare, underanv?ndare eller kunder till en specifik release-kanal."
"description": "Koppla användare, underanvändare eller kunder till en specifik release-kanal."
},
"deployments": {
"label": "Utrullningar",
@@ -1531,58 +1541,58 @@
},
"replay": {
"label": "Replay",
"description": "Aktivera riktad insamling och s?k i release-tidslinjeh?ndelser."
"description": "Aktivera riktad insamling och sök i release-tidslinjehändelser."
},
"integrations": {
"label": "Integrationer",
"description": "Anslut GitHub-repositories, branches och Coolify-tj?nster."
"description": "Anslut GitHub-repositories, branches och Coolify-tjänster."
},
"settings": {
"label": "Inst?llningar",
"description": "Konfigurera Release Managers GitHub-?tkomst och webhook-inst?llningar."
"label": "Inställningar",
"description": "Konfigurera Release Managers GitHub-åtkomst och webhook-inställningar."
}
},
"setup": {
"channels": "Kanaler",
"targets": "GitHub- och Coolify-m?l",
"targets": "GitHub- och Coolify-mål",
"assignments": "Pilottilldelningar",
"deployments": "F?rsta utrullningen",
"deployments": "Första utrullningen",
"replay": "Replay-insamling"
},
"stats": {
"channels": "Kanaler",
"targets": "M?l",
"targets": "Mål",
"deployments": "Utrullningar",
"timeline_events": "Tidslinjeh?ndelser"
"timeline_events": "Tidslinjehändelser"
},
"overview": {
"title": "?versikt",
"subtitle": "Aktuell kanalh?lsa, release-aktivitet och modulstatus.",
"title": "Översikt",
"subtitle": "Aktuell kanalhälsa, release-aktivitet och modulstatus.",
"guided_setup": "Guidad installation",
"next_step": "N?sta: {step}",
"ready": "Release Manager ?r klar f?r daglig drift.",
"next_step": "Nästa: {step}",
"ready": "Release Manager är klar för daglig drift.",
"add_suggested_channel": "Lägg till föreslagen kanal",
"module_health_empty": "Modulhälsosnapshots visas när probes har registrerats."
},
"settings": {
"title": "Release Manager-inst?llningar",
"subtitle": "Konfigurera GitHub-token som anv?nds f?r privata repositories och branch-uppslag.",
"title": "Release Manager-inställningar",
"subtitle": "Konfigurera GitHub-token som används för privata repositories och branch-uppslag.",
"github_token": "GitHub-token",
"github_api_url": "GitHub API-URL",
"webhook_secret": "Webhook-hemlighet",
"github_webhook_secret": "GitHub webhook-hemlighet",
"configured": "Konfigurerad",
"not_configured": "Inte konfigurerad",
"loaded_from": "Inl?st fr?n {variable}",
"loaded_from": "Inläst från {variable}",
"set_below": "Ange {variable} nedan",
"private_repositories_prefix": "Privata repositories l?ses med serverns milj?variabel",
"private_repositories_prefix": "Privata repositories läses med serverns miljövariabel",
"private_repositories_or": "eller modulens konfigurationsvariabel",
"github_token_message": "Exempel: github_pat_... med ?tkomst till de privata repositories som Release Manager distribuerar.",
"github_token_placeholder": "L?mna tomt f?r att beh?lla befintlig token",
"github_api_url_message": "Konfigurerad i ReleaseManager.github_api_url. Anv?nd https://api.github.com om du inte anv?nder GitHub Enterprise.",
"webhook_secret_message": "Valfritt; l?mna tomt f?r att beh?lla befintlig hemlighet.",
"github_token_message": "Exempel: github_pat_... med åtkomst till de privata repositories som Release Manager distribuerar.",
"github_token_placeholder": "Lämna tomt för att behålla befintlig token",
"github_api_url_message": "Konfigurerad i ReleaseManager.github_api_url. Använd https://api.github.com om du inte använder GitHub Enterprise.",
"webhook_secret_message": "Valfritt; lämna tomt för att behålla befintlig hemlighet.",
"webhook_secret_placeholder": "Webhook HMAC-hemlighet",
"save": "Spara inst?llningar",
"save": "Spara inställningar",
"back_to_integrations": "Tillbaka till integrationer",
"guide": {
"steps": {
@@ -1615,13 +1625,13 @@
},
"channel_unavailable": {
"kicker": "Release Manager",
"title": "Release-kanalen ?r inte klar",
"summary_prefix": "Ditt konto ?r tilldelat",
"summary_suffix": ", men kanalen saknar konfigurationen som beh?vs f?r att l?sa in dess release-image.",
"title": "Release-kanalen är inte klar",
"summary_prefix": "Ditt konto är tilldelat",
"summary_suffix": ", men kanalen saknar konfigurationen som behövs för att läsa in dess release-image.",
"frontend_url": "Frontend-URL",
"api_url": "API-URL",
"checking_again": "Kontrollerar igen om {seconds}s",
"refresh_error": "Release-status kunde inte uppdateras. N?sta automatiska kontroll f?rs?ker igen.",
"refresh_error": "Release-status kunde inte uppdateras. Nästa automatiska kontroll försöker igen.",
"ignore": "Ignorera de kommande 5 minuterna",
"check_again": "Kontrollera igen",
"logout": "Logga ut",
@@ -1629,9 +1639,9 @@
},
"channel_switched": {
"kicker": "Release Manager",
"title": "Du ?r nu p? {channel}",
"title": "Du är nu på {channel}",
"summary_prefix": "Ditt konto har tilldelats release-kanalen",
"summary_suffix": "Den h?r enheten kommer ih?g att du har sett detta meddelande.",
"summary_suffix": "Den här enheten kommer ihåg att du har sett detta meddelande.",
"frontend": "Frontend",
"api": "API",
"frontend_version": "Frontend-version",
@@ -1640,7 +1650,7 @@
"current_app_image": "Nuvarande app-image",
"current_api": "Nuvarande API",
"base_image": "Bas-image",
"continue": "Forts?tt"
"continue": "Fortsätt"
},
"common": {
"yes": "ja",
@@ -1820,7 +1830,7 @@
"repository_message": "Eksempel: truckwash/front-end-vue",
"repository_placeholder": "ejer/repo",
"branch": "Branch",
"branch_message": "Eksempel: main",
"branch_message": "Eksempel: master",
"branch_placeholder": "branch",
"commit_selection": "Commit-val",
"commit_selection_message": "Senaste slås upp som branchens head med konfigurerad GitHub-token.",
@@ -302,14 +302,24 @@
"release_manager": {
"title": "Release Manager",
"subtitle": "Gradvise API- og Front-End-udgivelser",
"channel_names": {
"stable": "Stabil",
"canary": "Canary",
"internal": "Intern"
},
"channel_descriptions": {
"stable": "Standard produktionskanal.",
"canary": "Tidlig produktionsvalideringskanal.",
"internal": "Intern kanal til medarbejdere og superbruger-validering."
},
"control_api": {
"title": "Kontrol-API",
"tooltip": "Release Manager-handlinger sendes til denne API. Brug produktions-API'en, medmindre du tester en staging-backend.",
"example": "Eksempel: https://api.truckwash.io:4433",
"endpoint_label": "API-endpoint",
"endpoint_message": "Skift kun dette, n?r Release Manager-endpoints er tilg?ngelige p? m?l-API'en.",
"endpoint_message": "Skift kun dette, når Release Manager-endpoints er tilgængelige på mål-API'en.",
"endpoint_aria": "Release Manager kontrol-API URL",
"use_tooltip": "Indl?s release-data fra denne API",
"use_tooltip": "Indlæs release-data fra denne API",
"use": "Brug",
"reset_tooltip": "Vend tilbage til standard kontrol-API",
"reset": "Nulstil",
@@ -318,15 +328,15 @@
"tabs": {
"overview": {
"label": "Oversigt",
"description": "Kanalstatus, ops?tningsfremdrift og seneste release-tilstand."
"description": "Kanalstatus, opsætningsfremdrift og seneste release-tilstand."
},
"channels": {
"label": "Kanaler",
"description": "Opret stabile, canary- og m?lrettede kanaler med rollout-gr?nser."
"description": "Opret stabile, canary- og målrettede kanaler med rollout-grænser."
},
"assignments": {
"label": "Tildelinger",
"description": "Fastg?r brugere, subbrugere eller kunder til en bestemt release-kanal."
"description": "Fastgør brugere, subbrugere eller kunder til en bestemt release-kanal."
},
"deployments": {
"label": "Udrulninger",
@@ -334,7 +344,7 @@
},
"replay": {
"label": "Replay",
"description": "Aktiv?r m?lrettet opsamling og s?g i release-tidslinjeh?ndelser."
"description": "Aktivér målrettet opsamling og søg i release-tidslinjehændelser."
},
"integrations": {
"label": "Integrationer",
@@ -347,22 +357,22 @@
},
"setup": {
"channels": "Kanaler",
"targets": "GitHub- og Coolify-m?l",
"targets": "GitHub- og Coolify-mål",
"assignments": "Pilot-tildelinger",
"deployments": "F?rste udrulning",
"deployments": "Første udrulning",
"replay": "Replay-opsamling"
},
"stats": {
"channels": "Kanaler",
"targets": "M?l",
"targets": "Mål",
"deployments": "Udrulninger",
"timeline_events": "Tidslinjeh?ndelser"
"timeline_events": "Tidslinjehændelser"
},
"overview": {
"title": "Oversigt",
"subtitle": "Aktuel kanalstatus, release-aktivitet og modulstatus.",
"guided_setup": "Guidet ops?tning",
"next_step": "N?ste: {step}",
"guided_setup": "Guidet opsætning",
"next_step": "Næste: {step}",
"ready": "Release Manager er klar til daglig drift.",
"add_suggested_channel": "Tilføj foreslået kanal",
"module_health_empty": "Modul-health snapshots vises, når probes er blevet registreret."
@@ -376,14 +386,14 @@
"github_webhook_secret": "GitHub webhook-hemmelighed",
"configured": "Konfigureret",
"not_configured": "Ikke konfigureret",
"loaded_from": "Indl?st fra {variable}",
"loaded_from": "Indlæst fra {variable}",
"set_below": "Angiv {variable} nedenfor",
"private_repositories_prefix": "Private repositories l?ses med serverens milj?variabel",
"private_repositories_prefix": "Private repositories læses med serverens miljøvariabel",
"private_repositories_or": "eller modulets konfigurationsvariabel",
"github_token_message": "Eksempel: github_pat_... med adgang til de private repositories, Release Manager udruller.",
"github_token_placeholder": "Lad feltet v?re tomt for at beholde det eksisterende token",
"github_token_placeholder": "Lad feltet være tomt for at beholde det eksisterende token",
"github_api_url_message": "Konfigureret i ReleaseManager.github_api_url. Brug https://api.github.com medmindre GitHub Enterprise bruges.",
"webhook_secret_message": "Valgfrit; lad feltet v?re tomt for at beholde den eksisterende hemmelighed.",
"webhook_secret_message": "Valgfrit; lad feltet være tomt for at beholde den eksisterende hemmelighed.",
"webhook_secret_placeholder": "Webhook HMAC-hemmelighed",
"save": "Gem indstillinger",
"back_to_integrations": "Tilbage til integrationer",
@@ -420,19 +430,19 @@
"kicker": "Release Manager",
"title": "Release-kanalen er ikke klar",
"summary_prefix": "Din konto er tildelt",
"summary_suffix": ", men kanalen mangler den konfiguration, der skal bruges for at indl?se dens release-image.",
"summary_suffix": ", men kanalen mangler den konfiguration, der skal bruges for at indlæse dens release-image.",
"frontend_url": "Frontend-URL",
"api_url": "API-URL",
"checking_again": "Tjekker igen om {seconds}s",
"refresh_error": "Release-status kunne ikke opdateres. Det n?ste automatiske tjek pr?ver igen.",
"ignore": "Ignorer de n?ste 5 minutter",
"refresh_error": "Release-status kunne ikke opdateres. Det næste automatiske tjek prøver igen.",
"ignore": "Ignorer de næste 5 minutter",
"check_again": "Tjek igen",
"logout": "Log ud",
"base_image": "Basis-image"
},
"channel_switched": {
"kicker": "Release Manager",
"title": "Du er nu p? {channel}",
"title": "Du er nu på {channel}",
"summary_prefix": "Din konto er blevet tildelt release-kanalen",
"summary_suffix": "Denne enhed husker, at du har set denne besked.",
"frontend": "Frontend",
@@ -440,10 +450,10 @@
"frontend_version": "Frontend-version",
"api_version": "API-version",
"assigned_channel": "Tildelt kanal",
"current_app_image": "Nuv?rende app-image",
"current_api": "Nuv?rende API",
"current_app_image": "Nuværende app-image",
"current_api": "Nuværende API",
"base_image": "Basis-image",
"continue": "Forts?t"
"continue": "Fortsæt"
},
"common": {
"yes": "ja",
@@ -623,7 +633,7 @@
"repository_message": "Eksempel: truckwash/front-end-vue",
"repository_placeholder": "ejer/repo",
"branch": "Branch",
"branch_message": "Eksempel: main",
"branch_message": "Eksempel: master",
"branch_placeholder": "branch",
"commit_selection": "Commit-valg",
"commit_selection_message": "Seneste slås op som branchens head med den konfigurerede GitHub-token.",
@@ -302,27 +302,37 @@
"release_manager": {
"title": "Release Manager",
"subtitle": "Schrittweise API- und Front-End-Releases",
"channel_names": {
"stable": "Stabil",
"canary": "Canary",
"internal": "Intern"
},
"channel_descriptions": {
"stable": "Standard-Produktionskanal.",
"canary": "Früher Produktionsvalidierungskanal.",
"internal": "Interner Kanal für Mitarbeitende und Superuser-Validierung."
},
"control_api": {
"title": "Kontroll-API",
"tooltip": "Release-Manager-Aktionen werden an diese API gesendet. Verwende die Produktions-Kontroll-API, au?er du testest ein Staging-Backend.",
"tooltip": "Release-Manager-Aktionen werden an diese API gesendet. Verwende die Produktions-Kontroll-API, außer du testest ein Staging-Backend.",
"example": "Beispiel: https://api.truckwash.io:4433",
"endpoint_label": "API-Endpunkt",
"endpoint_message": "?ndere dies nur, wenn Release-Manager-Endpunkte auf der Ziel-API verf?gbar sind.",
"endpoint_message": "Ändere dies nur, wenn Release-Manager-Endpunkte auf der Ziel-API verfügbar sind.",
"endpoint_aria": "Release Manager Kontroll-API-URL",
"use_tooltip": "Release-Daten von dieser API laden",
"use": "Verwenden",
"reset_tooltip": "Zur Standard-Kontroll-API zur?ckkehren",
"reset": "Zur?cksetzen",
"reset_tooltip": "Zur Standard-Kontroll-API zurückkehren",
"reset": "Zurücksetzen",
"known_endpoints": "Bekannte Kontroll-API-Endpunkte"
},
"tabs": {
"overview": {
"label": "?bersicht",
"label": "Übersicht",
"description": "Kanalzustand, Einrichtungsfortschritt und aktueller Release-Status."
},
"channels": {
"label": "Kan?le",
"description": "Stabile, Canary- und Zielkan?le mit Rollout-Grenzen erstellen."
"label": "Kanäle",
"description": "Stabile, Canary- und Zielkanäle mit Rollout-Grenzen erstellen."
},
"assignments": {
"label": "Zuweisungen",
@@ -346,30 +356,30 @@
}
},
"setup": {
"channels": "Kan?le",
"channels": "Kanäle",
"targets": "GitHub- und Coolify-Ziele",
"assignments": "Pilot-Zuweisungen",
"deployments": "Erste Bereitstellung",
"replay": "Replay-Erfassung"
},
"stats": {
"channels": "Kan?le",
"channels": "Kanäle",
"targets": "Ziele",
"deployments": "Bereitstellungen",
"timeline_events": "Timeline-Ereignisse"
},
"overview": {
"title": "?bersicht",
"subtitle": "Aktueller Kanalzustand, Release-Aktivit?t und Modulstatus.",
"guided_setup": "Gef?hrte Einrichtung",
"next_step": "N?chster Schritt: {step}",
"ready": "Release Manager ist f?r den t?glichen Betrieb bereit.",
"title": "Übersicht",
"subtitle": "Aktueller Kanalzustand, Release-Aktivität und Modulstatus.",
"guided_setup": "Geführte Einrichtung",
"next_step": "Nächster Schritt: {step}",
"ready": "Release Manager ist für den täglichen Betrieb bereit.",
"add_suggested_channel": "Vorgeschlagenen Kanal hinzufügen",
"module_health_empty": "Modul-Health-Snapshots erscheinen, nachdem Probes aufgezeichnet wurden."
},
"settings": {
"title": "Release-Manager-Einstellungen",
"subtitle": "GitHub-Token f?r private Repositories und Branch-Abfragen konfigurieren.",
"subtitle": "GitHub-Token für private Repositories und Branch-Abfragen konfigurieren.",
"github_token": "GitHub-Token",
"github_api_url": "GitHub-API-URL",
"webhook_secret": "Webhook-Secret",
@@ -386,7 +396,7 @@
"webhook_secret_message": "Optional; leer lassen, um das vorhandene Secret zu behalten.",
"webhook_secret_placeholder": "Webhook-HMAC-Secret",
"save": "Einstellungen speichern",
"back_to_integrations": "Zur?ck zu Integrationen",
"back_to_integrations": "Zurück zu Integrationen",
"guide": {
"steps": {
"github_token": "GitHub-Token",
@@ -423,10 +433,10 @@
"summary_suffix": ", aber diesem Kanal fehlt die Konfiguration, um sein Release-Image zu laden.",
"frontend_url": "Frontend-URL",
"api_url": "API-URL",
"checking_again": "Erneute Pr?fung in {seconds}s",
"refresh_error": "Release-Status konnte nicht aktualisiert werden. Die n?chste automatische Pr?fung versucht es erneut.",
"ignore": "N?chste 5 Minuten ignorieren",
"check_again": "Erneut pr?fen",
"checking_again": "Erneute Prüfung in {seconds}s",
"refresh_error": "Release-Status konnte nicht aktualisiert werden. Die nächste automatische Prüfung versucht es erneut.",
"ignore": "Nächste 5 Minuten ignorieren",
"check_again": "Erneut prüfen",
"logout": "Abmelden",
"base_image": "Basis-Image"
},
@@ -434,7 +444,7 @@
"kicker": "Release Manager",
"title": "Du bist jetzt auf {channel}",
"summary_prefix": "Dein Konto wurde dem Release-Kanal",
"summary_suffix": "zugewiesen. Dieses Ger?t merkt sich, dass du diesen Hinweis gesehen hast.",
"summary_suffix": "zugewiesen. Dieses Gerät merkt sich, dass du diesen Hinweis gesehen hast.",
"frontend": "Frontend",
"api": "API",
"frontend_version": "Frontend-Version",
@@ -623,7 +633,7 @@
"repository_message": "Example: truckwash/front-end-vue",
"repository_placeholder": "owner/repo",
"branch": "Branch",
"branch_message": "Example: main",
"branch_message": "Example: master",
"branch_placeholder": "branch",
"commit_selection": "Commit-Auswahl",
"commit_selection_message": "Neueste Version wird mit dem konfigurierten GitHub-Token als Branch-Head aufgelöst.",
@@ -302,6 +302,16 @@
"release_manager": {
"title": "Release Manager",
"subtitle": "Gradual API and Front-End releases",
"channel_names": {
"stable": "Stable",
"canary": "Canary",
"internal": "Internal"
},
"channel_descriptions": {
"stable": "Default production channel.",
"canary": "Early production validation channel.",
"internal": "Internal staff and superuser validation channel."
},
"control_api": {
"title": "Control API",
"tooltip": "Release Manager actions are sent to this API. Use the production control API unless testing a staged backend.",
@@ -623,7 +633,7 @@
"repository_message": "Example: truckwash/front-end-vue",
"repository_placeholder": "owner/repo",
"branch": "Branch",
"branch_message": "Example: main",
"branch_message": "Example: master",
"branch_placeholder": "branch",
"commit_selection": "Commit selection",
"commit_selection_message": "Latest resolves to the branch head with the configured GitHub token.",
@@ -355,6 +355,16 @@
"release_manager": {
"title": "@:{'phrases.compat.configuration.release_manager.title'}",
"subtitle": "@:{'phrases.compat.configuration.release_manager.subtitle'}",
"channel_names": {
"stable": "@:{'phrases.compat.configuration.release_manager.channel_names.stable'}",
"canary": "@:{'phrases.compat.configuration.release_manager.channel_names.canary'}",
"internal": "@:{'phrases.compat.configuration.release_manager.channel_names.internal'}"
},
"channel_descriptions": {
"stable": "@:{'phrases.compat.configuration.release_manager.channel_descriptions.stable'}",
"canary": "@:{'phrases.compat.configuration.release_manager.channel_descriptions.canary'}",
"internal": "@:{'phrases.compat.configuration.release_manager.channel_descriptions.internal'}"
},
"control_api": {
"title": "@:{'phrases.compat.configuration.release_manager.control_api.title'}",
"tooltip": "@:{'phrases.compat.configuration.release_manager.control_api.tooltip'}",
@@ -302,16 +302,26 @@
"release_manager": {
"title": "Release Manager",
"subtitle": "Gradvise API- og Front-End-utgivelser",
"channel_names": {
"stable": "Stabil",
"canary": "Canary",
"internal": "Intern"
},
"channel_descriptions": {
"stable": "Standard produksjonskanal.",
"canary": "Tidlig produksjonsvalideringskanal.",
"internal": "Intern kanal for ansatte og superbrukervalidering."
},
"control_api": {
"title": "Kontroll-API",
"tooltip": "Release Manager-handlinger sendes til denne API-en. Bruk produksjons-API-en med mindre du tester en staging-backend.",
"example": "Eksempel: https://api.truckwash.io:4433",
"endpoint_label": "API-endepunkt",
"endpoint_message": "Endre dette bare n?r Release Manager-endepunkter er tilgjengelige p? m?l-API-en.",
"endpoint_message": "Endre dette bare når Release Manager-endepunkter er tilgjengelige på mål-API-en.",
"endpoint_aria": "Release Manager kontroll-API URL",
"use_tooltip": "Last release-data fra denne API-en",
"use": "Bruk",
"reset_tooltip": "G? tilbake til standard kontroll-API",
"reset_tooltip": "Gå tilbake til standard kontroll-API",
"reset": "Tilbakestill",
"known_endpoints": "Kjente kontroll-API-endepunkter"
},
@@ -322,7 +332,7 @@
},
"channels": {
"label": "Kanaler",
"description": "Opprett stabile, canary- og m?lrettede kanaler med rollout-grenser."
"description": "Opprett stabile, canary- og målrettede kanaler med rollout-grenser."
},
"assignments": {
"label": "Tildelinger",
@@ -334,7 +344,7 @@
},
"replay": {
"label": "Replay",
"description": "Aktiver m?lrettet innsamling og s?k i release-tidslinjehendelser."
"description": "Aktiver målrettet innsamling og søk i release-tidslinjehendelser."
},
"integrations": {
"label": "Integrasjoner",
@@ -347,14 +357,14 @@
},
"setup": {
"channels": "Kanaler",
"targets": "GitHub- og Coolify-m?l",
"targets": "GitHub- og Coolify-mål",
"assignments": "Pilottildelinger",
"deployments": "F?rste utrulling",
"deployments": "Første utrulling",
"replay": "Replay-innsamling"
},
"stats": {
"channels": "Kanaler",
"targets": "M?l",
"targets": "Mål",
"deployments": "Utrullinger",
"timeline_events": "Tidslinjehendelser"
},
@@ -378,12 +388,12 @@
"not_configured": "Ikke konfigurert",
"loaded_from": "Lastet fra {variable}",
"set_below": "Angi {variable} nedenfor",
"private_repositories_prefix": "Private repositories leses med serverens milj?variabel",
"private_repositories_prefix": "Private repositories leses med serverens miljøvariabel",
"private_repositories_or": "eller modulens konfigurasjonsvariabel",
"github_token_message": "Eksempel: github_pat_... med tilgang til de private repositories Release Manager ruller ut.",
"github_token_placeholder": "La st? tomt for ? beholde eksisterende token",
"github_token_placeholder": "La stå tomt for å beholde eksisterende token",
"github_api_url_message": "Konfigurert i ReleaseManager.github_api_url. Bruk https://api.github.com med mindre GitHub Enterprise brukes.",
"webhook_secret_message": "Valgfritt; la st? tomt for ? beholde eksisterende hemmelighet.",
"webhook_secret_message": "Valgfritt; la stå tomt for å beholde eksisterende hemmelighet.",
"webhook_secret_placeholder": "Webhook HMAC-hemmelighet",
"save": "Lagre innstillinger",
"back_to_integrations": "Tilbake til integrasjoner",
@@ -420,11 +430,11 @@
"kicker": "Release Manager",
"title": "Release-kanalen er ikke klar",
"summary_prefix": "Kontoen din er tildelt",
"summary_suffix": ", men kanalen mangler konfigurasjonen som trengs for ? laste release-imaget.",
"summary_suffix": ", men kanalen mangler konfigurasjonen som trengs for å laste release-imaget.",
"frontend_url": "Frontend-URL",
"api_url": "API-URL",
"checking_again": "Sjekker igjen om {seconds}s",
"refresh_error": "Release-status kunne ikke oppdateres. Neste automatiske sjekk pr?ver igjen.",
"refresh_error": "Release-status kunne ikke oppdateres. Neste automatiske sjekk prøver igjen.",
"ignore": "Ignorer de neste 5 minuttene",
"check_again": "Sjekk igjen",
"logout": "Logg ut",
@@ -432,7 +442,7 @@
},
"channel_switched": {
"kicker": "Release Manager",
"title": "Du er n? p? {channel}",
"title": "Du er nå på {channel}",
"summary_prefix": "Kontoen din er tildelt release-kanalen",
"summary_suffix": "Denne enheten husker at du har sett denne meldingen.",
"frontend": "Frontend",
@@ -440,8 +450,8 @@
"frontend_version": "Frontend-versjon",
"api_version": "API-versjon",
"assigned_channel": "Tildelt kanal",
"current_app_image": "N?v?rende app-image",
"current_api": "N?v?rende API",
"current_app_image": "Nåværende app-image",
"current_api": "Nåværende API",
"base_image": "Basis-image",
"continue": "Fortsett"
},
@@ -623,7 +633,7 @@
"repository_message": "Eksempel: truckwash/front-end-vue",
"repository_placeholder": "ejer/repo",
"branch": "Branch",
"branch_message": "Eksempel: main",
"branch_message": "Eksempel: master",
"branch_placeholder": "branch",
"commit_selection": "Commit-valg",
"commit_selection_message": "Seneste slås op som branchens head med den konfigurerede GitHub-token.",
@@ -302,31 +302,41 @@
"release_manager": {
"title": "Release Manager",
"subtitle": "Gradvisa API- och Front-End-versioner",
"channel_names": {
"stable": "Stabil",
"canary": "Canary",
"internal": "Intern"
},
"channel_descriptions": {
"stable": "Standardkanal för produktion.",
"canary": "Tidig produktionsvalideringskanal.",
"internal": "Intern kanal för personal och superanvändarvalidering."
},
"control_api": {
"title": "Kontroll-API",
"tooltip": "Release Manager-?tg?rder skickas till detta API. Anv?nd produktions-API:t om du inte testar en staging-backend.",
"tooltip": "Release Manager-åtgärder skickas till detta API. Använd produktions-API:t om du inte testar en staging-backend.",
"example": "Exempel: https://api.truckwash.io:4433",
"endpoint_label": "API-endpoint",
"endpoint_message": "?ndra detta endast n?r Release Manager-endpoints finns p? m?l-API:t.",
"endpoint_message": "Ändra detta endast när Release Manager-endpoints finns på mål-API:t.",
"endpoint_aria": "Release Manager kontroll-API URL",
"use_tooltip": "L?s in release-data fr?n detta API",
"use": "Anv?nd",
"reset_tooltip": "G? tillbaka till standard kontroll-API",
"reset": "?terst?ll",
"known_endpoints": "K?nda kontroll-API-endpoints"
"use_tooltip": "Läs in release-data från detta API",
"use": "Använd",
"reset_tooltip": "Gå tillbaka till standard kontroll-API",
"reset": "Återställ",
"known_endpoints": "Kända kontroll-API-endpoints"
},
"tabs": {
"overview": {
"label": "?versikt",
"description": "Kanalh?lsa, installationsstatus och senaste release-l?ge."
"label": "Översikt",
"description": "Kanalhälsa, installationsstatus och senaste release-läge."
},
"channels": {
"label": "Kanaler",
"description": "Skapa stabila, canary- och riktade kanaler med rollout-gr?nser."
"description": "Skapa stabila, canary- och riktade kanaler med rollout-gränser."
},
"assignments": {
"label": "Tilldelningar",
"description": "Koppla anv?ndare, underanv?ndare eller kunder till en specifik release-kanal."
"description": "Koppla användare, underanvändare eller kunder till en specifik release-kanal."
},
"deployments": {
"label": "Utrullningar",
@@ -334,58 +344,58 @@
},
"replay": {
"label": "Replay",
"description": "Aktivera riktad insamling och s?k i release-tidslinjeh?ndelser."
"description": "Aktivera riktad insamling och sök i release-tidslinjehändelser."
},
"integrations": {
"label": "Integrationer",
"description": "Anslut GitHub-repositories, branches och Coolify-tj?nster."
"description": "Anslut GitHub-repositories, branches och Coolify-tjänster."
},
"settings": {
"label": "Inst?llningar",
"description": "Konfigurera Release Managers GitHub-?tkomst och webhook-inst?llningar."
"label": "Inställningar",
"description": "Konfigurera Release Managers GitHub-åtkomst och webhook-inställningar."
}
},
"setup": {
"channels": "Kanaler",
"targets": "GitHub- och Coolify-m?l",
"targets": "GitHub- och Coolify-mål",
"assignments": "Pilottilldelningar",
"deployments": "F?rsta utrullningen",
"deployments": "Första utrullningen",
"replay": "Replay-insamling"
},
"stats": {
"channels": "Kanaler",
"targets": "M?l",
"targets": "Mål",
"deployments": "Utrullningar",
"timeline_events": "Tidslinjeh?ndelser"
"timeline_events": "Tidslinjehändelser"
},
"overview": {
"title": "?versikt",
"subtitle": "Aktuell kanalh?lsa, release-aktivitet och modulstatus.",
"title": "Översikt",
"subtitle": "Aktuell kanalhälsa, release-aktivitet och modulstatus.",
"guided_setup": "Guidad installation",
"next_step": "N?sta: {step}",
"ready": "Release Manager ?r klar f?r daglig drift.",
"next_step": "Nästa: {step}",
"ready": "Release Manager är klar för daglig drift.",
"add_suggested_channel": "Lägg till föreslagen kanal",
"module_health_empty": "Modulhälsosnapshots visas när probes har registrerats."
},
"settings": {
"title": "Release Manager-inst?llningar",
"subtitle": "Konfigurera GitHub-token som anv?nds f?r privata repositories och branch-uppslag.",
"title": "Release Manager-inställningar",
"subtitle": "Konfigurera GitHub-token som används för privata repositories och branch-uppslag.",
"github_token": "GitHub-token",
"github_api_url": "GitHub API-URL",
"webhook_secret": "Webhook-hemlighet",
"github_webhook_secret": "GitHub webhook-hemlighet",
"configured": "Konfigurerad",
"not_configured": "Inte konfigurerad",
"loaded_from": "Inl?st fr?n {variable}",
"loaded_from": "Inläst från {variable}",
"set_below": "Ange {variable} nedan",
"private_repositories_prefix": "Privata repositories l?ses med serverns milj?variabel",
"private_repositories_prefix": "Privata repositories läses med serverns miljövariabel",
"private_repositories_or": "eller modulens konfigurationsvariabel",
"github_token_message": "Exempel: github_pat_... med ?tkomst till de privata repositories som Release Manager distribuerar.",
"github_token_placeholder": "L?mna tomt f?r att beh?lla befintlig token",
"github_api_url_message": "Konfigurerad i ReleaseManager.github_api_url. Anv?nd https://api.github.com om du inte anv?nder GitHub Enterprise.",
"webhook_secret_message": "Valfritt; l?mna tomt f?r att beh?lla befintlig hemlighet.",
"github_token_message": "Exempel: github_pat_... med åtkomst till de privata repositories som Release Manager distribuerar.",
"github_token_placeholder": "Lämna tomt för att behålla befintlig token",
"github_api_url_message": "Konfigurerad i ReleaseManager.github_api_url. Använd https://api.github.com om du inte använder GitHub Enterprise.",
"webhook_secret_message": "Valfritt; lämna tomt för att behålla befintlig hemlighet.",
"webhook_secret_placeholder": "Webhook HMAC-hemlighet",
"save": "Spara inst?llningar",
"save": "Spara inställningar",
"back_to_integrations": "Tillbaka till integrationer",
"guide": {
"steps": {
@@ -418,13 +428,13 @@
},
"channel_unavailable": {
"kicker": "Release Manager",
"title": "Release-kanalen ?r inte klar",
"summary_prefix": "Ditt konto ?r tilldelat",
"summary_suffix": ", men kanalen saknar konfigurationen som beh?vs f?r att l?sa in dess release-image.",
"title": "Release-kanalen är inte klar",
"summary_prefix": "Ditt konto är tilldelat",
"summary_suffix": ", men kanalen saknar konfigurationen som behövs för att läsa in dess release-image.",
"frontend_url": "Frontend-URL",
"api_url": "API-URL",
"checking_again": "Kontrollerar igen om {seconds}s",
"refresh_error": "Release-status kunde inte uppdateras. N?sta automatiska kontroll f?rs?ker igen.",
"refresh_error": "Release-status kunde inte uppdateras. Nästa automatiska kontroll försöker igen.",
"ignore": "Ignorera de kommande 5 minuterna",
"check_again": "Kontrollera igen",
"logout": "Logga ut",
@@ -432,9 +442,9 @@
},
"channel_switched": {
"kicker": "Release Manager",
"title": "Du ?r nu p? {channel}",
"title": "Du är nu på {channel}",
"summary_prefix": "Ditt konto har tilldelats release-kanalen",
"summary_suffix": "Den h?r enheten kommer ih?g att du har sett detta meddelande.",
"summary_suffix": "Den här enheten kommer ihåg att du har sett detta meddelande.",
"frontend": "Frontend",
"api": "API",
"frontend_version": "Frontend-version",
@@ -443,7 +453,7 @@
"current_app_image": "Nuvarande app-image",
"current_api": "Nuvarande API",
"base_image": "Bas-image",
"continue": "Forts?tt"
"continue": "Fortsätt"
},
"common": {
"yes": "ja",
@@ -623,7 +633,7 @@
"repository_message": "Eksempel: truckwash/front-end-vue",
"repository_placeholder": "ejer/repo",
"branch": "Branch",
"branch_message": "Eksempel: main",
"branch_message": "Eksempel: master",
"branch_placeholder": "branch",
"commit_selection": "Commit-val",
"commit_selection_message": "Senaste slås upp som branchens head med konfigurerad GitHub-token.",
+93 -5
View File
@@ -221,6 +221,98 @@ export const getRecentFrontendFailureEvents = () =>
payload: redactReleasePayload(event.payload),
}));
const browserInfoFromUserAgent = (userAgent = "") => {
const ua = String(userAgent || "");
const matchers = [
["Edge", /Edg\/([\d.]+)/],
["Chrome", /Chrome\/([\d.]+)/],
["Firefox", /Firefox\/([\d.]+)/],
["Safari", /Version\/([\d.]+).*Safari/],
];
for (const [name, pattern] of matchers) {
const match = ua.match(pattern);
if (match) {
return { name, version: match[1] || null };
}
}
return { name: "Unknown", version: null };
};
const osInfoFromUserAgent = (userAgent = "") => {
const ua = String(userAgent || "");
const matchers = [
["Windows", /Windows NT ([\d.]+)/],
["Android", /Android ([\d.]+)/],
["iOS", /(?:iPhone|iPad).*OS ([\d_]+)/],
["macOS", /Mac OS X ([\d_]+)/],
["Linux", /Linux/],
];
for (const [name, pattern] of matchers) {
const match = ua.match(pattern);
if (match) {
return { name, version: match[1] ? String(match[1]).replace(/_/g, ".") : null };
}
}
return { name: "Unknown", version: null };
};
const currentDeviceType = () => {
if (typeof window === "undefined") {
return null;
}
const width = Number(window.innerWidth || 0);
if (width > 0 && width < 769) {
return "mobile";
}
if (width >= 769 && width < 1024) {
return "tablet";
}
return "desktop";
};
const versionLabel = (version) => version?.version_label || version?.label || null;
const commitSha = (version) => version?.commit_sha || version?.commit || null;
export const buildReleaseTimelineContext = () => {
const userAgent = typeof navigator !== "undefined" ? navigator.userAgent : "";
const browser = browserInfoFromUserAgent(userAgent);
const os = osInfoFromUserAgent(userAgent);
const frontendVersion = releaseRuntimeStateMutable.versions?.frontend || {};
const apiVersion = releaseRuntimeStateMutable.versions?.api || {};
const fallbackFrontendVersion = import.meta.env.VITE_COMMIT_HASH || "unknown";
return {
trace_id: releaseRuntimeStateMutable.traceId,
channel_slug: releaseRuntimeStateMutable.channel?.slug || null,
route_path: typeof window !== "undefined" ? window.location.pathname : null,
device: {
type: currentDeviceType(),
},
browser,
os,
viewport:
typeof window !== "undefined"
? {
width: window.innerWidth,
height: window.innerHeight,
device_pixel_ratio: window.devicePixelRatio || 1,
}
: null,
frontend: {
version_label: versionLabel(frontendVersion) || fallbackFrontendVersion,
commit_sha: commitSha(frontendVersion) || fallbackFrontendVersion,
},
api: {
version_label: versionLabel(apiVersion),
commit_sha: commitSha(apiVersion),
},
frontend_version: versionLabel(frontendVersion) || fallbackFrontendVersion,
frontend_commit_sha: commitSha(frontendVersion) || fallbackFrontendVersion,
api_version: versionLabel(apiVersion),
api_commit_sha: commitSha(apiVersion),
};
};
const scheduleReleaseTimelineFlush = () => {
if (flushTimer !== null || typeof window === "undefined") {
return;
@@ -242,11 +334,7 @@ export const flushReleaseTimelineEvents = async () => {
const body = {
events,
context: {
trace_id: releaseRuntimeStateMutable.traceId,
channel_slug: releaseRuntimeStateMutable.channel?.slug || null,
frontend_version: import.meta.env.VITE_COMMIT_HASH || "unknown",
},
context: buildReleaseTimelineContext(),
};
try {
+9
View File
@@ -190,6 +190,9 @@ export const listReleaseServiceSets = () => requestReleaseManager("/superuser/re
export const createReleaseServiceSet = (payload) =>
requestReleaseManager("/superuser/releases/service-sets", "POST", payload);
export const completeReleaseServiceSetIsolatedDataServices = (id, payload = {}) =>
requestReleaseManager(`/superuser/releases/service-sets/${id}/isolated-data-services`, "POST", payload);
export const listReleaseBundles = (limit = 50) =>
requestReleaseManager("/superuser/releases/bundles", "GET", { limit });
@@ -228,3 +231,9 @@ export const setReleaseReplayTarget = (payload) =>
export const searchReleaseTimeline = (filters = {}) =>
requestReleaseManager("/superuser/releases/timeline", "GET", filters);
export const listReleaseTimelineSessions = (filters = {}) =>
requestReleaseManager("/superuser/releases/timeline/sessions", "GET", filters);
export const getReleaseTimelineSession = (traceId) =>
requestReleaseManager(`/superuser/releases/timeline/sessions/${encodeURIComponent(traceId)}`, "GET", {});
@@ -8,6 +8,7 @@ import PageTitle from "@/components/global/PageTitle.vue";
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
import ConfigurationSubPageWrapper from "@/views/dashboards/superUserDashboard/configuration/ConfigurationSubPageWrapper.vue";
import ConfigurationError from "@/components/displays/superuser/configuration/ConfigurationError.vue";
import ReleaseReplayInspector from "@/components/release/ReleaseReplayInspector.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import {
isSearching as isSearchingCustomers,
@@ -16,6 +17,7 @@ import {
} from "@/components/search/economic/customerSearch.vue";
import {
clearReleaseManagerControlApiUrl,
completeReleaseServiceSetIsolatedDataServices,
createReleaseAssignment,
createReleaseBundle,
createReleaseChannel,
@@ -33,8 +35,6 @@ import {
promoteReleaseDeployment,
rollbackReleaseChannel,
saveReleaseDeploymentTarget,
searchReleaseTimeline,
setReleaseReplayTarget,
releaseManagerControlApiCandidates,
startReleaseDeployment,
setReleaseManagerControlApiUrl,
@@ -50,6 +50,7 @@ const trFallback = (key, fallback, params = {}) => {
const translated = t(fullKey, params);
return translated === fullKey ? fallback : translated;
};
const DEFAULT_RELEASE_BRANCH = "master";
const valueLabel = (group, value) => trFallback(`values.${group}.${value}`, String(value || ""));
const readableValue = (value) => String(value || "").replace(/_/g, " ");
const statusLabel = (value) => trFallback(`values.status.${value}`, readableValue(value));
@@ -114,7 +115,6 @@ const summary = ref({
module_health: [],
suggestions: {},
});
const timelineEvents = ref([]);
const errors = ref([]);
const busy = ref(null);
const releaseConfig = ref({});
@@ -178,10 +178,12 @@ const targetForm = reactive({
channel_id: null,
app: "frontend",
repository: "",
branch: "main",
branch: DEFAULT_RELEASE_BRANCH,
coolify_instance_id: "",
coolify_project_uuid: "",
coolify_service_uuid: "",
coolify_github_app_uuid: "",
coolify_build_pack: "nixpacks",
health_url: "",
auto_deploy: true,
coolify_auto_create: false,
@@ -195,7 +197,7 @@ const deploymentForm = reactive({
channel_id: null,
app: "frontend",
repository: "",
branch: "main",
branch: DEFAULT_RELEASE_BRANCH,
commit_mode: "latest",
commit_sha: "",
version_label: "",
@@ -207,34 +209,18 @@ const bundleForm = reactive({
service_set_name: "",
channel_id: null,
frontend_repository: "",
frontend_branch: "main",
frontend_branch: DEFAULT_RELEASE_BRANCH,
frontend_commit_mode: "latest",
frontend_commit_sha: "",
frontend_image: "",
api_repository: "",
api_branch: "main",
api_branch: DEFAULT_RELEASE_BRANCH,
api_commit_mode: "latest",
api_commit_sha: "",
api_image: "",
version_label: "",
});
const replayForm = reactive({
target_type: "user",
target_id: "",
channel_id: null,
capture_level: "full_redacted",
expires_at: "",
});
const timelineFilters = reactive({
trace_id: "",
channel_slug: "",
principal_type: "",
principal_id: "",
module_key: "",
severity: "",
limit: 100,
});
const canView = computed(
() => SessionUser.canAccessSuperUser() || SessionUser.hasPermission("superuser_release_manager_view")
);
@@ -269,7 +255,7 @@ const repositorySuggestions = computed(() =>
);
const branchSuggestions = computed(() =>
uniqueStrings([
...(Array.isArray(suggestions.value.branches) ? suggestions.value.branches : ["main"]),
...(Array.isArray(suggestions.value.branches) ? suggestions.value.branches : [DEFAULT_RELEASE_BRANCH]),
...githubBranches.value.map((branch) => branch?.name || "").filter(Boolean),
])
);
@@ -388,7 +374,7 @@ const datasetModeMessage = computed(() =>
isIsolatedStackMode.value
? trFallback(
"bundles.isolated_stack_message",
"Creates new Coolify frontend and API services and leaves data services unattached so production data is not used."
"Creates new Coolify frontend, API, database, Redis, and MinIO services without attaching production data."
)
: trFallback(
"bundles.dataset_mode_message",
@@ -400,7 +386,7 @@ const bundleReviewStack = computed(() => [
key: "frontend",
label: trFallback("bundles.stack.frontend", "Frontend"),
value: bundleForm.frontend_repository || frontendBundleSourceTarget.value?.repository || "--",
detail: `${bundleForm.frontend_branch || frontendBundleSourceTarget.value?.branch || "main"} ${
detail: `${bundleForm.frontend_branch || frontendBundleSourceTarget.value?.branch || DEFAULT_RELEASE_BRANCH} ${
bundleForm.frontend_commit_mode === "specific" ? bundleForm.frontend_commit_sha : trFallback("bundles.latest", "latest")
}`,
status: isIsolatedStackMode.value
@@ -413,7 +399,7 @@ const bundleReviewStack = computed(() => [
key: "api",
label: trFallback("bundles.stack.api", "PHP backend"),
value: bundleForm.api_repository || apiBundleSourceTarget.value?.repository || "--",
detail: `${bundleForm.api_branch || apiBundleSourceTarget.value?.branch || "main"} ${
detail: `${bundleForm.api_branch || apiBundleSourceTarget.value?.branch || DEFAULT_RELEASE_BRANCH} ${
bundleForm.api_commit_mode === "specific" ? bundleForm.api_commit_sha : trFallback("bundles.latest", "latest")
}`,
status: isIsolatedStackMode.value
@@ -452,7 +438,10 @@ const canContinueReleaseFlow = computed(() => {
String(bundleForm.frontend_commit_sha || "").trim() !== "") &&
(!bundleForm.api_commit_mode ||
bundleForm.api_commit_mode === "latest" ||
String(bundleForm.api_commit_sha || "").trim() !== "")
String(bundleForm.api_commit_sha || "").trim() !== "") &&
(!isIsolatedStackMode.value ||
((Boolean(isolatedStackImage("frontend")) || Boolean(isolatedStackGithubAppUuid("frontend"))) &&
(Boolean(isolatedStackImage("api")) || Boolean(isolatedStackGithubAppUuid("api")))))
);
}
return true;
@@ -884,14 +873,6 @@ function controlApiOptions() {
});
}
function moduleKeyOptions() {
return autocompleteOptions(moduleKeySuggestions.value, timelineFilters.module_key, {
category: tr("autocomplete.module"),
description: tr("autocomplete.timeline_module_key"),
icon: "fas fa-puzzle-piece",
});
}
function selectChannelUrl(kind, option) {
if (!option) {
return;
@@ -944,13 +925,6 @@ function selectControlApi(option) {
controlApiInput.value = optionValue(option);
}
function selectModuleKey(option) {
if (!option) {
return;
}
timelineFilters.module_key = optionValue(option);
}
function selectCoolifyService(service) {
if (!service) {
return;
@@ -982,7 +956,7 @@ function prepareCoolifySslTarget(app, url) {
repositorySuggestions.value.filter((repository) => /front|vue|web/i.test(repository)),
targetForm.repository
),
branch: targetForm.branch || firstAvailable(branchSuggestions.value, "main"),
branch: targetForm.branch || firstAvailable(branchSuggestions.value, DEFAULT_RELEASE_BRANCH),
coolify_instance_id: targetForm.coolify_instance_id || coolifyInstances.value[0]?.id || "",
coolify_project_uuid: targetForm.coolify_project_uuid || defaultCoolifyProjectUuid(targetForm.coolify_instance_id || coolifyInstances.value[0]?.id),
health_url: healthUrlForBaseUrl(app, baseUrl),
@@ -1027,10 +1001,12 @@ function applyTargetPreset(preset) {
channel_id: targetForm.channel_id || defaultChannel.value?.id || null,
app: preset.app || "frontend",
repository: preset.repository || firstAvailable(repositorySuggestions.value),
branch: preset.branch || firstAvailable(branchSuggestions.value, "main"),
branch: preset.branch || firstAvailable(branchSuggestions.value, DEFAULT_RELEASE_BRANCH),
coolify_instance_id: targetForm.coolify_instance_id || coolifyInstances.value[0]?.id || "",
coolify_project_uuid: preset.coolify_project_uuid || targetForm.coolify_project_uuid || defaultCoolifyProjectUuid(targetForm.coolify_instance_id || coolifyInstances.value[0]?.id),
coolify_service_uuid: preset.coolify_service_uuid || firstAvailable(serviceUuidSuggestions.value),
coolify_github_app_uuid: preset.coolify_github_app_uuid || targetForm.coolify_github_app_uuid || "",
coolify_build_pack: preset.coolify_build_pack || targetForm.coolify_build_pack || "nixpacks",
health_url: preset.health_url || suggestedHealthUrl(preset.app || "frontend"),
auto_deploy: preset.auto_deploy !== false,
coolify_auto_create: Boolean(preset.coolify_auto_create),
@@ -1094,10 +1070,16 @@ function applyBundleDefaults() {
firstAvailable(repositorySuggestions.value.filter((repository) => /api|backend|php/i.test(repository)));
}
if (!bundleForm.frontend_branch) {
bundleForm.frontend_branch = selectedServiceSet.value?.targets?.frontend?.branch || "main";
bundleForm.frontend_branch = selectedServiceSet.value?.targets?.frontend?.branch || DEFAULT_RELEASE_BRANCH;
}
if (!bundleForm.api_branch) {
bundleForm.api_branch = selectedServiceSet.value?.targets?.api?.branch || "main";
bundleForm.api_branch = selectedServiceSet.value?.targets?.api?.branch || DEFAULT_RELEASE_BRANCH;
}
if (isIsolatedStackMode.value && !bundleForm.frontend_image) {
bundleForm.frontend_image = firstImageFromContext(frontendBundleSourceTarget.value?.deploy_context || {});
}
if (isIsolatedStackMode.value && !bundleForm.api_image) {
bundleForm.api_image = firstImageFromContext(apiBundleSourceTarget.value?.deploy_context || {});
}
if (!bundleForm.version_label && bundleForm.channel_id) {
const channelSlug = channels.value.find((channel) => Number(channel.id) === Number(bundleForm.channel_id))?.slug || "release";
@@ -1124,9 +1106,6 @@ async function load() {
bundleForm.channel_id = channels.value[0].id;
}
applyBundleDefaults();
if (!replayForm.channel_id && channels.value[0]) {
replayForm.channel_id = channels.value[0].id;
}
} catch (error) {
errors.value.push(error);
} finally {
@@ -1278,6 +1257,17 @@ function serviceSetStackItem(set, item) {
return set?.stack?.[item] || null;
}
function missingIsolatedDataServices(set) {
if (set?.mode !== "isolated_stack") {
return [];
}
return ["database", "redis", "minio"].filter((kind) => !serviceSetStackItem(set, kind));
}
function canCompleteIsolatedDataServices(set) {
return canDeploy.value && missingIsolatedDataServices(set).length > 0 && serviceSetStackItem(set, "frontend") && serviceSetStackItem(set, "api");
}
function bundleApp(bundle, app) {
return bundle?.apps?.[app] || {};
}
@@ -1320,11 +1310,76 @@ function isolatedStackProjectUuid(instanceId, sourceTarget) {
return sourceTarget?.deploy_context?.coolify_project_uuid || defaultCoolifyProjectUuid(instanceId);
}
function safeEnvironmentSlug(value) {
return String(value || "")
.toLowerCase()
.replace(/^refs\/heads\//, "")
.replace(/[^a-z0-9_-]+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 64);
}
function releaseCoolifyEnvironmentName(channelSlug, branch) {
const channel = safeEnvironmentSlug(channelSlug);
if (!channel || ["stable", "production", "prod", "beta"].includes(channel)) {
return "";
}
const branchSlug = safeEnvironmentSlug(branch);
if (branchSlug && !["main", "master"].includes(branchSlug)) {
return branchSlug;
}
return channel;
}
function firstImageFromContext(context = {}) {
for (const key of ["image", "docker_image", "coolify_image", "coolify_docker_image", "registry_image"]) {
const value = context[key];
if (typeof value === "string" && value.trim() !== "") {
return value.trim();
}
}
return "";
}
function isolatedStackImage(app) {
const formImage = String(bundleForm[`${app}_image`] || "").trim();
return formImage || firstImageFromContext(isolatedStackSourceTarget(app)?.deploy_context || {});
}
function isolatedStackImageContext(app) {
const image = isolatedStackImage(app);
return image ? { image } : {};
}
function isolatedStackGithubAppUuid(app) {
const context = isolatedStackSourceTarget(app)?.deploy_context || {};
return String(
context.coolify_github_app_uuid ||
context.github_app_uuid ||
context.coolify_git_app_uuid ||
context.git_app_uuid ||
""
).trim();
}
function isolatedStackPullContext(app) {
const context = isolatedStackSourceTarget(app)?.deploy_context || {};
const githubAppUuid = isolatedStackGithubAppUuid(app);
if (!githubAppUuid) {
return {};
}
return {
coolify_github_app_uuid: githubAppUuid,
coolify_build_pack: String(context.coolify_build_pack || context.build_pack || (app === "api" ? "dockerfile" : "nixpacks")).trim(),
};
}
async function createIsolatedStackDeploymentTarget(app) {
const sourceTarget = isolatedStackSourceTarget(app);
const instanceId = Number(sourceTarget?.coolify_instance_id || coolifyInstances.value[0]?.id || 0) || null;
const repository = bundleForm[`${app}_repository`] || sourceTarget?.repository || "";
const branch = bundleForm[`${app}_branch`] || sourceTarget?.branch || "main";
const branch = bundleForm[`${app}_branch`] || sourceTarget?.branch || DEFAULT_RELEASE_BRANCH;
if (!instanceId) {
throw new Error(
trFallback("errors.isolated_stack_coolify_required", "Select or configure a Coolify instance before creating an isolated stack.")
@@ -1347,12 +1402,16 @@ async function createIsolatedStackDeploymentTarget(app) {
health_url: "",
auto_deploy: true,
deploy_context: {
...isolatedStackPullContext(app),
...isolatedStackImageContext(app),
coolify_auto_create: true,
coolify_enable_ssl: false,
coolify_domain: "",
coolify_public_url: "",
coolify_deploy_now: true,
coolify_project_uuid: isolatedStackProjectUuid(instanceId, sourceTarget),
coolify_environment_uuid: "",
coolify_environment_name: releaseCoolifyEnvironmentName(selectedBundleChannel()?.slug || "", branch),
coolify_service_name: isolatedStackServiceName(app),
isolated_stack: true,
production_data_attached: false,
@@ -1366,6 +1425,15 @@ async function createIsolatedStackDeploymentTarget(app) {
return savedTarget;
}
async function completeIsolatedDataServices(set) {
await run(`service-set:${set.id}:isolated-data`, async () => {
await completeReleaseServiceSetIsolatedDataServices(set.id, {
deploy_data_targets: true,
});
await load();
});
}
function selectBundleField(app, field, option) {
if (!option) {
return;
@@ -1430,6 +1498,8 @@ async function serviceSetIdForBundle() {
}`,
frontend_target_id: isolatedTargets?.frontend?.id || frontendBundleTarget.value?.id || null,
api_target_id: isolatedTargets?.api?.id || apiBundleTarget.value?.id || null,
create_data_targets: isIsolatedStackMode.value,
deploy_data_targets: isIsolatedStackMode.value,
metadata: isIsolatedStackMode.value
? {
isolated_stack: true,
@@ -1573,6 +1643,8 @@ async function saveTarget() {
coolify_public_url: coolifyDomain ? `https://${coolifyDomain}` : "",
coolify_deploy_now: Boolean(targetForm.coolify_deploy_now),
coolify_project_uuid: targetForm.coolify_project_uuid || "",
coolify_github_app_uuid: String(targetForm.coolify_github_app_uuid || "").trim(),
coolify_build_pack: String(targetForm.coolify_build_pack || "").trim() || "nixpacks",
};
const savedResponse = await saveReleaseDeploymentTarget({
...targetForm,
@@ -1598,10 +1670,12 @@ async function saveTarget() {
id: null,
app: "frontend",
repository: "",
branch: "main",
branch: DEFAULT_RELEASE_BRANCH,
coolify_instance_id: "",
coolify_project_uuid: "",
coolify_service_uuid: "",
coolify_github_app_uuid: "",
coolify_build_pack: "nixpacks",
health_url: "",
auto_deploy: true,
coolify_auto_create: false,
@@ -1659,20 +1733,6 @@ async function rollbackChannel(channel) {
});
}
async function enableReplayTarget() {
await run("replay:target", async () => {
await setReleaseReplayTarget({ ...replayForm, enabled: true });
Object.assign(replayForm, { target_id: "", expires_at: "" });
await load();
});
}
async function loadTimeline() {
await run("timeline:search", async () => {
timelineEvents.value = responseData(await searchReleaseTimeline({ ...timelineFilters }), []);
});
}
async function run(key, callback) {
busy.value = key;
errors.value = [];
@@ -1830,6 +1890,7 @@ watch(
const channelSlug = channels.value.find((channel) => Number(channel.id) === Number(bundleForm.channel_id))?.slug || "release";
bundleForm.service_set_name = `${channelSlug}-${mode.replace(/_/g, "-")}-${new Date().toISOString().slice(0, 10)}`;
}
applyBundleDefaults();
}
);
@@ -1866,7 +1927,6 @@ onMounted(async () => {
await load();
await loadReleaseConfig();
await loadGithubRepositories();
await loadTimeline();
});
</script>
@@ -2461,6 +2521,17 @@ onMounted(async () => {
>
{{ trFallback("bundles.clone", "Clone") }}
</b-button>
<b-button
v-if="canCompleteIsolatedDataServices(set)"
size="is-small"
icon-left="database"
icon-pack="fas"
:loading="busy === `service-set:${set.id}:isolated-data`"
:data-testid="`release-service-set-complete-isolated-data-${set.id}`"
@click="completeIsolatedDataServices(set)"
>
{{ trFallback("bundles.add_data_services", "Add data services") }}
</b-button>
</div>
</article>
</div>
@@ -2479,7 +2550,7 @@ onMounted(async () => {
:label="trFallback('bundles.channel', 'Channel')"
:message="trFallback('bundles.channel_message', 'The channel that will receive this full-stack bundle.')"
>
<b-select v-model.number="bundleForm.channel_id" expanded required>
<b-select v-model.number="bundleForm.channel_id" data-testid="release-bundle-channel" expanded required>
<option v-for="channel in channels" :key="channel.id" :value="channel.id">{{ channel.slug }}</option>
</b-select>
</b-field>
@@ -2547,13 +2618,20 @@ onMounted(async () => {
v-model="bundleForm.frontend_branch"
:data="branchOptions(bundleForm.frontend_branch)"
field="value"
placeholder="main"
:placeholder="DEFAULT_RELEASE_BRANCH"
open-on-focus
keep-first
expanded
@select="(option) => selectBundleField('frontend', 'branch', option)"
/>
</b-field>
<b-field
v-if="isIsolatedStackMode"
:label="trFallback('bundles.container_image', 'Container image')"
:message="trFallback('bundles.container_image_message', 'Image must be pullable by the selected Coolify server.')"
>
<b-input v-model="bundleForm.frontend_image" placeholder="ghcr.io/org/frontend:tag" />
</b-field>
<b-field :label="tr('deployments.commit_selection')" :message="tr('deployments.commit_selection_message')">
<b-select v-model="bundleForm.frontend_commit_mode" expanded>
<option value="latest">{{ tr("deployments.latest_from_branch") }}</option>
@@ -2588,13 +2666,20 @@ onMounted(async () => {
v-model="bundleForm.api_branch"
:data="branchOptions(bundleForm.api_branch)"
field="value"
placeholder="main"
:placeholder="DEFAULT_RELEASE_BRANCH"
open-on-focus
keep-first
expanded
@select="(option) => selectBundleField('api', 'branch', option)"
/>
</b-field>
<b-field
v-if="isIsolatedStackMode"
:label="trFallback('bundles.container_image', 'Container image')"
:message="trFallback('bundles.container_image_message', 'Image must be pullable by the selected Coolify server.')"
>
<b-input v-model="bundleForm.api_image" placeholder="ghcr.io/org/api:tag" />
</b-field>
<b-field :label="tr('deployments.commit_selection')" :message="tr('deployments.commit_selection_message')">
<b-select v-model="bundleForm.api_commit_mode" expanded>
<option value="latest">{{ tr("deployments.latest_from_branch") }}</option>
@@ -3002,128 +3087,12 @@ onMounted(async () => {
icon="fas fa-clock-rotate-left"
default-expanded
>
<div class="release-chip-row" data-testid="release-replay-target-suggestions">
<b-button
v-for="channel in channels"
:key="channel.id"
size="is-small"
type="is-light"
icon-left="circle"
icon-pack="fas"
@click="
Object.assign(replayForm, { target_type: 'channel', target_id: channel.slug, channel_id: channel.id })
"
>
{{ tr("replay.capture_channel", { channel: channel.slug }) }}
</b-button>
</div>
<form class="release-form" data-testid="release-replay-form" @submit.prevent="enableReplayTarget">
<b-field :label="tr('replay.target_type')">
<b-select v-model="replayForm.target_type" expanded>
<option value="user">{{ valueLabel("subject_type", "user") }}</option>
<option value="subuser">{{ valueLabel("subject_type", "subuser") }}</option>
<option value="customer">{{ valueLabel("subject_type", "customer") }}</option>
<option value="channel">{{ valueLabel("subject_type", "channel") }}</option>
</b-select>
</b-field>
<b-field :label="tr('replay.target')" :message="tr('replay.target_message')">
<b-input v-model="replayForm.target_id" :placeholder="tr('replay.target_placeholder')" />
</b-field>
<b-field :label="tr('replay.channel_scope')" :message="tr('replay.channel_scope_message')">
<b-select v-model.number="replayForm.channel_id" expanded>
<option :value="null">{{ tr("replay.no_channel_scope") }}</option>
<option v-for="channel in channels" :key="channel.id" :value="channel.id">{{ channel.slug }}</option>
</b-select>
</b-field>
<b-field :label="tr('replay.capture_level')">
<b-select v-model="replayForm.capture_level" expanded>
<option value="full_redacted">{{ valueLabel("capture_level", "full_redacted") }}</option>
<option value="metadata">{{ valueLabel("capture_level", "metadata") }}</option>
</b-select>
</b-field>
<b-field :label="tr('replay.expires')" :message="tr('replay.expires_message')">
<b-input v-model="replayForm.expires_at" type="datetime-local" />
</b-field>
<div class="release-form-actions">
<b-button
type="is-dark"
native-type="submit"
icon-left="play"
icon-pack="fas"
:disabled="!canReplay"
:loading="busy === 'replay:target'"
>
{{ tr("actions.enable") }}
</b-button>
</div>
</form>
<form class="release-form mt-3" data-testid="release-timeline-filter-form" @submit.prevent="loadTimeline">
<b-field :label="tr('replay.trace_id')" :message="tr('replay.trace_id_message')">
<b-input v-model="timelineFilters.trace_id" :placeholder="tr('replay.trace_id_placeholder')" />
</b-field>
<b-field :label="tr('replay.channel')">
<b-input v-model="timelineFilters.channel_slug" :placeholder="tr('replay.channel_placeholder')" />
</b-field>
<b-field :label="tr('replay.principal_id')">
<b-input v-model="timelineFilters.principal_id" :placeholder="tr('replay.principal_id_placeholder')" />
</b-field>
<b-field :label="tr('replay.module')">
<BAutocomplete
v-model="timelineFilters.module_key"
:data="moduleKeyOptions()"
field="value"
:placeholder="tr('replay.module_placeholder')"
open-on-focus
keep-first
expanded
@select="selectModuleKey"
>
<template #default="slotProps">
<div class="release-autocomplete-option">
<i :class="slotProps.option.icon" aria-hidden="true"></i>
<span>
<strong>{{ slotProps.option.title }}</strong>
<small>{{ slotProps.option.description }}</small>
</span>
</div>
</template>
</BAutocomplete>
</b-field>
<b-field :label="tr('replay.severity')">
<b-select v-model="timelineFilters.severity" expanded>
<option value="">{{ tr("replay.any_severity") }}</option>
<option value="error">{{ valueLabel("severity", "error") }}</option>
<option value="warning">{{ valueLabel("severity", "warning") }}</option>
<option value="info">{{ valueLabel("severity", "info") }}</option>
</b-select>
</b-field>
<div class="release-form-actions">
<b-button
native-type="submit"
icon-left="search"
icon-pack="fas"
:disabled="!canReplay"
:loading="busy === 'timeline:search'"
>
{{ tr("actions.search") }}
</b-button>
</div>
</form>
<ol class="release-timeline" data-testid="release-timeline-events">
<li v-for="event in timelineEvents" :key="event.id">
<time>{{ event.occurred_at }}</time>
<strong>{{ event.event_type }}</strong>
<b-tag class="ml-1" :class="statusClass(event.severity)">{{ valueLabel("severity", event.severity) }}</b-tag>
<div class="release-muted">
{{ event.channel_slug || "--" }} / {{ event.module_key || "--" }} /
{{ event.route_path || event.component || "--" }}
</div>
</li>
<li v-if="timelineEvents.length === 0">{{ tr("replay.no_timeline_events") }}</li>
</ol>
<ReleaseReplayInspector
:channels="channels"
:module-keys="moduleKeySuggestions"
:can-replay="canReplay"
@refresh-summary="load"
/>
</ConfigurationCategory>
<ConfigurationCategory
@@ -3145,7 +3114,7 @@ onMounted(async () => {
>
<strong>{{ preset.label }}</strong>
<span>{{ preset.repository || tr("integrations.choose_repository") }}</span>
<small>{{ preset.branch || "main" }} / {{ preset.health_url || tr("integrations.add_health_url") }}</small>
<small>{{ preset.branch || DEFAULT_RELEASE_BRANCH }} / {{ preset.health_url || tr("integrations.add_health_url") }}</small>
</button>
</div>
@@ -3306,6 +3275,27 @@ onMounted(async () => {
</template>
</BAutocomplete>
</b-field>
<b-field
:label="trFallback('integrations.coolify_github_app_uuid', 'Coolify GitHub App UUID')"
:message="trFallback('integrations.coolify_github_app_uuid_message', 'Used when Release Manager creates a new Coolify application so repository pulls use the GitHub App token.')"
>
<b-input
v-model="targetForm.coolify_github_app_uuid"
placeholder="github-app-uuid"
data-testid="release-target-coolify-github-app-uuid"
/>
</b-field>
<b-field
:label="trFallback('integrations.coolify_build_pack', 'Coolify build pack')"
:message="trFallback('integrations.coolify_build_pack_message', 'Build pack for GitHub App based applications.')"
>
<b-select v-model="targetForm.coolify_build_pack" expanded data-testid="release-target-coolify-build-pack">
<option value="nixpacks">nixpacks</option>
<option value="dockerfile">dockerfile</option>
<option value="dockercompose">dockercompose</option>
<option value="static">static</option>
</b-select>
</b-field>
<b-field :label="tr('integrations.health_url')" :message="tr('integrations.health_url_message')">
<BAutocomplete
v-model="targetForm.health_url"
+2 -2
View File
@@ -59,7 +59,7 @@ async function boot(page) {
test("users assigned to a configured release channel see the switched notice once on the device", async ({ page }) => {
await boot(page);
await page.goto("/user");
await page.goto("/user", { waitUntil: "domcontentloaded" });
const notice = page.getByTestId("release-channel-switched-page");
await expect(notice).toBeVisible();
await expect(notice).toContainText("You are now on Canary");
@@ -70,6 +70,6 @@ test("users assigned to a configured release channel see the switched notice onc
await page.getByTestId("release-channel-switched-continue").click();
await expect(notice).toHaveCount(0);
await page.goto("/user");
await page.goto("/user", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("release-channel-switched-page")).toHaveCount(0);
});
+33 -6
View File
@@ -52,11 +52,11 @@ const availableRuntime = (frontendBaseUrl) => ({
},
});
async function boot(page, runtime = unavailableRuntime) {
await page.addInitScript(() => {
window.localStorage.setItem("locale", "en");
async function boot(page, runtime = unavailableRuntime, locale = "en") {
await page.addInitScript((selectedLocale) => {
window.localStorage.setItem("locale", selectedLocale);
window.localStorage.removeItem("release_channel_unavailable_ignore_until");
});
}, locale);
await mockApi(page, {
authenticated: true,
@@ -76,7 +76,7 @@ test("users assigned to an unconfigured release channel can ignore the guard tem
await route.fulfill(json({ data: unavailableRuntime }));
});
await page.goto("/user");
await page.goto("/user", { waitUntil: "domcontentloaded" });
const guard = page.getByTestId("release-channel-unavailable-page");
await expect(guard).toBeVisible();
await expect(guard).toContainText("Release channel is not ready");
@@ -90,6 +90,33 @@ test("users assigned to an unconfigured release channel can ignore the guard tem
await page.evaluate(() => window.localStorage.removeItem("release_channel_unavailable_ignore_until"));
});
test("predefined release channel text is localized on the guard page", async ({ page }) => {
const internalRuntime = {
...unavailableRuntime,
channel: {
...unavailableRuntime.channel,
slug: "internal",
name: "Internal",
description: "Internal staff and superuser validation channel.",
},
};
await boot(page, internalRuntime, "da");
await page.route("**/release/runtime", async (route) => {
await route.fulfill(json({ data: internalRuntime }));
});
await page.goto("/user", { waitUntil: "domcontentloaded" });
const guard = page.getByTestId("release-channel-unavailable-page");
await expect(guard).toBeVisible();
await expect(guard).toContainText("Release-kanalen er ikke klar");
await expect(guard).toContainText("Intern");
await expect(guard).toContainText("Intern kanal til medarbejdere");
await expect(guard).not.toContainText("Internal staff");
await expect(guard).not.toContainText(/\binternal\b/);
await expect(guard).not.toContainText(/\p{L}\?\p{L}|\?\p{L}/u);
});
test("users assigned to an unconfigured release channel can check again when it becomes ready", async ({ page }) => {
await boot(page);
let releaseRuntime = unavailableRuntime;
@@ -98,7 +125,7 @@ test("users assigned to an unconfigured release channel can check again when it
await route.fulfill(json({ data: releaseRuntime }));
});
await page.goto("/user");
await page.goto("/user", { waitUntil: "domcontentloaded" });
const guard = page.getByTestId("release-channel-unavailable-page");
await expect(guard).toBeVisible();
+278 -20
View File
@@ -1,6 +1,10 @@
import { expect, test } from "@playwright/test";
import { mockApi, seedAuthenticatedState } from "./support/network.js";
test.describe.configure({ mode: "serial", timeout: 180_000 });
const DEFAULT_RELEASE_BRANCH = "master";
const permissions = [
"superuser",
"user",
@@ -27,6 +31,7 @@ function createReleaseState() {
nextDeploymentId: 3,
nextServiceSetId: 2,
nextBundleId: 1,
nextCoolifyTargetId: 20,
channels: [
{
id: 1,
@@ -85,7 +90,7 @@ function createReleaseState() {
channel_slug: "canary",
app: "frontend",
repository: "truckwash/front-end-vue",
branch: "main",
branch: DEFAULT_RELEASE_BRANCH,
coolify_instance_id: 3,
coolify_instance_label: "Production Coolify",
coolify_service_uuid: "service-canary",
@@ -109,7 +114,7 @@ function createReleaseState() {
channel_slug: "canary",
app: "frontend",
repository: "truckwash/front-end-vue",
branch: "main",
branch: DEFAULT_RELEASE_BRANCH,
},
api: null,
},
@@ -172,7 +177,7 @@ function createReleaseState() {
channel_slug: "canary",
app: "frontend",
repository: "truckwash/front-end-vue",
branch: "main",
branch: DEFAULT_RELEASE_BRANCH,
commit_sha: "c0ffee",
version_label: "frontend-canary",
status: "deployed",
@@ -181,6 +186,49 @@ function createReleaseState() {
},
],
replayTargets: [],
timelineSessions: [
{
id: 7,
trace_id: "trace-canary-1",
principal_type: "user",
principal_id: "42",
customer_number: 424242,
user: {
type: "user",
id: "42",
customer_number: 424242,
name: "Acme Dispatcher",
email: "dispatcher@example.test",
label: "Acme Dispatcher",
},
channel_id: 2,
channel_slug: "canary",
release: {
frontend: { version_label: "frontend-canary", commit_sha: "c0ffee" },
api: { version_label: "api-canary", commit_sha: "def456" },
},
device: {
type: "desktop",
browser_name: "Chrome",
browser_version: "125.0.0",
os_name: "Windows",
os_version: "11",
viewport_width: 1440,
viewport_height: 900,
device_pixel_ratio: 1,
user_agent: "Mozilla/5.0 Chrome/125.0.0",
},
last_route_path: "/superuser/configuration/releases",
event_count: 1,
error_count: 1,
error_report_count: 1,
module_keys: ["requestqueue"],
first_event_at: "2026-05-19T08:10:00.000Z",
last_event_at: "2026-05-19T08:10:00.000Z",
created_at: "2026-05-19T08:09:00.000Z",
last_seen_at: "2026-05-19T08:10:00.000Z",
},
],
timelineEvents: [
{
id: 99,
@@ -192,6 +240,10 @@ function createReleaseState() {
route_path: "/superuser/configuration/releases",
component: null,
occurred_at: "2026-05-19T08:10:00.000Z",
payload: {
request: { url: "/superuser/releases", headers: { Authorization: "[redacted]" } },
response: { status: 500 },
},
},
],
};
@@ -225,13 +277,13 @@ function summary(state) {
},
],
timeline: {
sessions: 1,
sessions: state.timelineSessions.length,
events: state.timelineEvents.length,
},
module_keys: ["requestqueue", "frontend", "auth", "coolify"],
suggestions: {
repositories: ["truckwash/front-end-vue", "truckwash/backend-php"],
branches: ["main", "release/canary", "release/beta"],
branches: [DEFAULT_RELEASE_BRANCH, "release/canary", "release/beta"],
frontend_base_urls: ["http://localhost:5173", "https://canary.example.test", "https://app.example.test"],
api_base_urls: ["http://api.truckwash.io:4433", "https://api-canary.example.test", "https://api.example.test"],
health_urls: [
@@ -281,7 +333,7 @@ function summary(state) {
label: "Frontend target",
app: "frontend",
repository: "truckwash/front-end-vue",
branch: "main",
branch: DEFAULT_RELEASE_BRANCH,
health_url: "https://canary.example.test/health",
auto_deploy: true,
},
@@ -302,6 +354,44 @@ function channelSlug(state, id) {
return state.channels.find((channel) => Number(channel.id) === Number(id))?.slug || "stable";
}
function isolatedDataService(state, kind, stackName = "isolated-stack") {
const id = state.nextCoolifyTargetId++;
return {
id,
kind,
label: `release-${stackName.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${kind}`,
role: "replica",
resource_name: `release-${stackName.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${kind}-${id}`,
resource_uuid: `isolated-${kind}-${id}`,
deployment_status: "deploying",
availability_state: "degraded",
options: {
isolated_stack: true,
skip_replication_provisioning: true,
production_data_attached: false,
},
replication: {
id: id + 1000,
label: `release-${stackName.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${kind}`,
status: "unknown",
role: "replica",
source_host_id: null,
},
};
}
function completeIsolatedDataServices(state, serviceSet) {
serviceSet.data_services = serviceSet.data_services || {};
for (const kind of ["database", "redis", "minio"]) {
if (!serviceSet.data_services[kind]) {
serviceSet.data_services[kind] = isolatedDataService(state, kind, serviceSet.name || "isolated-stack");
}
}
serviceSet.status = "isolated_stack";
serviceSet.health = { status: "isolated_stack", stack_complete: true };
return serviceSet;
}
async function boot(page, state = createReleaseState()) {
await page.addInitScript(() => {
window.localStorage.setItem("locale", "en");
@@ -404,7 +494,7 @@ async function installReleaseMocks(page, state) {
full_name: "truckwash/front-end-vue",
name: "front-end-vue",
private: true,
default_branch: "main",
default_branch: DEFAULT_RELEASE_BRANCH,
description: "Truckwash dashboard frontend",
},
{
@@ -429,7 +519,7 @@ async function installReleaseMocks(page, state) {
token_configured: true,
repository,
branches: [
{ name: "main", commit_sha: "c0ffee", protected: true },
{ name: DEFAULT_RELEASE_BRANCH, commit_sha: "c0ffee", protected: true },
{ name: "release/canary", commit_sha: "feedface", protected: false },
],
},
@@ -440,7 +530,7 @@ async function installReleaseMocks(page, state) {
if (pathname.endsWith("/superuser/releases/github/commits") && method === "GET") {
const repository = url.searchParams.get("repository") || "truckwash/front-end-vue";
const branch = url.searchParams.get("branch") || "main";
const branch = url.searchParams.get("branch") || DEFAULT_RELEASE_BRANCH;
await route.fulfill(
json({
data: {
@@ -480,7 +570,7 @@ async function installReleaseMocks(page, state) {
token_configured: true,
message: "Repository and branch are accessible with the configured GitHub token.",
repository: payload.repository,
branch: payload.branch || "main",
branch: payload.branch || DEFAULT_RELEASE_BRANCH,
commit_mode: payload.commit_mode || "latest",
commit_sha: payload.commit_mode === "specific" ? payload.commit_sha : latestSha,
latest_commit_sha: latestSha,
@@ -524,7 +614,7 @@ async function installReleaseMocks(page, state) {
channel_slug: channelSlug(state, channelId),
app: "api",
repository: "truckwash/backend-php",
branch: "main",
branch: DEFAULT_RELEASE_BRANCH,
commit_sha: "rollback",
version_label: "rollback",
status: "rolled_back",
@@ -611,11 +701,26 @@ async function installReleaseMocks(page, state) {
},
attached_bundles: [],
};
if (payload.mode === "isolated_stack" && payload.create_data_targets !== false) {
completeIsolatedDataServices(state, serviceSet);
}
state.serviceSets.unshift(serviceSet);
await route.fulfill(json({ data: summary(state).service_sets.find((entry) => entry.id === serviceSet.id) }));
return;
}
if (/\/superuser\/releases\/service-sets\/\d+\/isolated-data-services$/.test(pathname) && method === "POST") {
const id = Number(pathname.match(/service-sets\/(\d+)\/isolated-data-services/)?.[1] || 0);
const serviceSet = state.serviceSets.find((entry) => Number(entry.id) === id);
if (!serviceSet || serviceSet.mode !== "isolated_stack") {
await route.fulfill(json({ message: "Only isolated stacks can add data services." }, 400));
return;
}
completeIsolatedDataServices(state, serviceSet);
await route.fulfill(json({ data: summary(state).service_sets.find((entry) => entry.id === serviceSet.id) }, 202));
return;
}
if (pathname.endsWith("/superuser/releases/bundles") && method === "GET") {
await route.fulfill(json({ data: state.bundles }));
return;
@@ -637,7 +742,7 @@ async function installReleaseMocks(page, state) {
version_id: state.nextBundleId * 10 + 1,
deployment_id: null,
repository: payload.frontend?.repository || "truckwash/front-end-vue",
branch: payload.frontend?.branch || "main",
branch: payload.frontend?.branch || DEFAULT_RELEASE_BRANCH,
commit_sha: payload.frontend?.commit_mode === "specific" ? payload.frontend?.commit_sha : "c0ffee",
},
api: {
@@ -706,7 +811,7 @@ async function installReleaseMocks(page, state) {
channel_slug: channelSlug(state, channelId),
app: payload.app || target?.app || "frontend",
repository: payload.repository || target?.repository || "truckwash/front-end-vue",
branch: payload.branch || target?.branch || "main",
branch: payload.branch || target?.branch || DEFAULT_RELEASE_BRANCH,
commit_sha: payload.commit_sha || "latestcommit",
version_label: payload.version_label || "manual-release",
status: "deploying",
@@ -739,6 +844,53 @@ async function installReleaseMocks(page, state) {
return;
}
if (pathname.endsWith("/superuser/releases/timeline/sessions") && method === "GET") {
await route.fulfill(json({ data: state.timelineSessions }));
return;
}
if (/\/superuser\/releases\/timeline\/sessions\/[^/]+$/.test(pathname) && method === "GET") {
const traceId = decodeURIComponent(pathname.split("/").pop() || "");
const session = state.timelineSessions.find((entry) => entry.trace_id === traceId);
await route.fulfill(
json({
data: {
session,
events: state.timelineEvents.filter((event) => event.trace_id === traceId),
error_reports: [
{
id: 17,
status: "open",
reporter: { type: "user", user_id: 42, name: "Acme Dispatcher" },
route_path: "/superuser/configuration/releases",
release_trace_id: traceId,
frontend_version: "frontend-canary",
api_version: "api-canary",
request_error_count: 1,
vue_error_count: 0,
created_at: "2026-05-19T08:11:00.000Z",
},
],
release: {
channel: state.channels.find((channel) => channel.slug === session?.channel_slug) || null,
frontend: {
...(session?.release?.frontend || {}),
version: { id: 12, app: "frontend", status: "deployed", version_label: "frontend-canary" },
deployment: { id: 21, status: "deployed", bundle_id: 31 },
},
api: {
...(session?.release?.api || {}),
version: { id: 13, app: "api", status: "deployed", version_label: "api-canary" },
deployment: { id: 22, status: "deployed", bundle_id: 31 },
},
bundle: { id: 31, version_label: "canary-bundle", status: "deployed" },
},
},
})
);
return;
}
if (pathname.endsWith("/superuser/releases/timeline") && method === "GET") {
await route.fulfill(json({ data: state.timelineEvents }));
return;
@@ -765,7 +917,7 @@ async function selectReleaseTab(page, name) {
test("superusers manage release channels, assignments, deployments, and replay", async ({ page }) => {
await boot(page);
await page.goto("/superuser/configuration/releases");
await page.goto("/superuser/configuration/releases", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("release-manager-page")).toBeVisible({ timeout: 30_000 });
await expect(page.getByRole("link", { name: /Release Manager/i }).first()).toBeVisible();
@@ -855,6 +1007,8 @@ test("superusers manage release channels, assignments, deployments, and replay",
await bundleFlow.getByRole("button", { name: "Next" }).click();
await expect(bundleFlow.getByPlaceholder("owner/frontend")).toHaveValue("truckwash/front-end-vue");
await expect(bundleFlow.getByPlaceholder("owner/backend-php")).toHaveValue("truckwash/backend-php");
await expect(bundleFlow.getByPlaceholder(DEFAULT_RELEASE_BRANCH).first()).toHaveValue(DEFAULT_RELEASE_BRANCH);
await expect(bundleFlow.getByPlaceholder(DEFAULT_RELEASE_BRANCH).nth(1)).toHaveValue(DEFAULT_RELEASE_BRANCH);
await bundleFlow.getByRole("button", { name: "Next" }).click();
await expect(page.getByTestId("release-bundle-review-stack")).toContainText("Frontend");
await expect(page.getByTestId("release-bundle-review-stack")).toContainText("PHP backend");
@@ -885,25 +1039,70 @@ test("superusers manage release channels, assignments, deployments, and replay",
await page.getByTestId("release-replay-form").getByRole("button", { name: "Enable" }).click();
await page.getByTestId("release-timeline-filter-form").getByPlaceholder("Trace id").fill("trace-canary-1");
await page.getByTestId("release-timeline-filter-form").getByRole("button", { name: "Search" }).click();
await expect(page.getByTestId("release-timeline-sessions")).toContainText("Acme Dispatcher");
await expect(page.getByTestId("release-timeline-sessions")).toContainText("desktop / Chrome / Windows");
await expect(page.getByTestId("release-timeline-events")).toContainText("request_failed");
const sessionActions = page.getByTestId("release-session-actions-trace-canary-1");
await sessionActions.getByRole("button").first().click();
await sessionActions.getByRole("button", { name: "Inspect" }).click();
await expect(page.getByTestId("release-timeline-session-modal")).toBeVisible();
await page.getByTestId("release-timeline-session-detail").getByRole("button", { name: "Release" }).click();
await expect(page.getByTestId("release-timeline-session-detail")).toContainText("frontend-canary");
await expect(page.getByTestId("release-timeline-session-detail")).toContainText("#21 deployed");
await expect(page.getByTestId("release-timeline-session-detail")).toContainText("#31 canary-bundle");
await page.getByTestId("release-timeline-session-detail").getByRole("button", { name: "Device" }).click();
await expect(page.getByTestId("release-timeline-session-detail")).toContainText("Chrome");
await page.getByTestId("release-timeline-session-detail").getByRole("button", { name: "Error reports" }).click();
await expect(page.getByTestId("release-timeline-session-detail")).toContainText("#17");
await expect(page.getByTestId("release-timeline-session-detail")).toContainText("Visual replay");
});
test("isolated stack mode creates fresh Coolify app targets without attaching production data", async ({ page }) => {
test("isolated stack mode creates fresh Coolify app and data targets without attaching production data", async ({
page,
}) => {
const state = await boot(page);
await page.goto("/superuser/configuration/releases");
state.targets[0].deploy_context = {
coolify_project_uuid: "project-internal",
coolify_github_app_uuid: "github-app-copenhagentruckwash-github",
coolify_build_pack: "nixpacks",
};
state.targets.push({
id: state.nextTargetId++,
channel_id: 2,
channel_slug: "canary",
app: "api",
repository: "truckwash/backend-php",
branch: "release/canary",
coolify_instance_id: 3,
coolify_instance_label: "Production Coolify",
coolify_service_uuid: "api-canary-service",
health_url: "https://api-canary.example.test/ping",
auto_deploy: true,
deploy_context: {
coolify_project_uuid: "project-internal",
coolify_github_app_uuid: "github-app-copenhagentruckwash-github",
coolify_build_pack: "dockerfile",
},
});
await page.goto("/superuser/configuration/releases", { waitUntil: "domcontentloaded" });
await selectReleaseTab(page, "Deployments");
await expandActiveReleaseCategory(page);
const bundleFlow = page.getByTestId("release-bundle-flow");
const bundleChannel = bundleFlow.getByTestId("release-bundle-channel");
await bundleChannel.selectOption("2");
await expect(bundleChannel).toHaveValue("2");
await bundleFlow.getByTestId("release-bundle-dataset-mode").selectOption("isolated_stack");
await expect(bundleFlow).toContainText("production data is not used");
await bundleChannel.selectOption("2");
await expect(bundleChannel).toHaveValue("2");
await expect(bundleFlow).toContainText("without attaching production data");
await expect(bundleFlow.getByTestId("release-bundle-source-service-set")).toBeDisabled();
await bundleFlow.getByPlaceholder("canary fresh data").fill("Internal safe stack");
await bundleFlow.getByRole("button", { name: "Next" }).click();
await expect(bundleFlow.getByPlaceholder("owner/frontend")).toHaveValue("truckwash/front-end-vue");
await expect(bundleFlow.getByPlaceholder("owner/backend-php")).toHaveValue("truckwash/backend-php");
await expect(bundleFlow.getByPlaceholder("owner/frontend", { exact: true })).toHaveValue("truckwash/front-end-vue");
await expect(bundleFlow.getByPlaceholder("owner/backend-php", { exact: true })).toHaveValue("truckwash/backend-php");
await bundleFlow.getByRole("button", { name: "Next" }).click();
await expect(page.getByTestId("release-bundle-review-stack")).toContainText("new isolated service");
@@ -920,10 +1119,69 @@ test("isolated stack mode creates fresh Coolify app targets without attaching pr
expect(target.deploy_context.coolify_auto_create).toBe(true);
expect(target.deploy_context.coolify_enable_ssl).toBe(false);
expect(target.deploy_context.production_data_attached).toBe(false);
expect(target.deploy_context.coolify_github_app_uuid).toBe("github-app-copenhagentruckwash-github");
expect(target.deploy_context.coolify_build_pack).toBe(target.app === "api" ? "dockerfile" : "nixpacks");
expect(target.deploy_context.image || "").toBe("");
expect(target.deploy_context.coolify_environment_name).toBe("canary");
expect(target.deploy_context.coolify_service_name).toMatch(/^release-internal-safe-stack-(api|frontend)$/);
}
const isolatedSet = state.serviceSets.find((set) => set.mode === "isolated_stack");
expect(isolatedSet.source_service_set_id).toBeNull();
expect(isolatedSet.data_services).toEqual({ database: null, redis: null, minio: null });
expect(Object.keys(isolatedSet.data_services).sort()).toEqual(["database", "minio", "redis"]);
for (const service of Object.values(isolatedSet.data_services)) {
expect(service.options.isolated_stack).toBe(true);
expect(service.options.production_data_attached).toBe(false);
expect(service.options.skip_replication_provisioning).toBe(true);
expect(service.replication.source_host_id).toBeNull();
}
});
test("existing isolated stacks can add missing data services safely", async ({ page }) => {
const state = createReleaseState();
state.serviceSets.unshift({
id: state.nextServiceSetId++,
channel_id: 2,
channel_slug: "canary",
name: "Internal isolated stack",
slug: "internal-isolated-stack",
mode: "isolated_stack",
status: "needs_isolated_targets",
targets: {
frontend: {
id: 101,
channel_id: 2,
channel_slug: "canary",
app: "frontend",
repository: "truckwash/front-end-vue",
branch: DEFAULT_RELEASE_BRANCH,
deploy_context: { isolated_stack: true, coolify_auto_create: true, production_data_attached: false },
},
api: {
id: 102,
channel_id: 2,
channel_slug: "canary",
app: "api",
repository: "truckwash/backend-php",
branch: "release/canary",
deploy_context: { isolated_stack: true, coolify_auto_create: true, production_data_attached: false },
},
},
data_services: { database: null, redis: null, minio: null },
attached_bundles: [],
});
await boot(page, state);
await page.goto("/superuser/configuration/releases", { waitUntil: "domcontentloaded" });
await selectReleaseTab(page, "Deployments");
await expandActiveReleaseCategory(page);
await expect(page.getByTestId("release-service-set-cards")).toContainText("Internal isolated stack");
await page.getByTestId("release-service-set-complete-isolated-data-2").click();
await expect(page.getByTestId("release-service-set-complete-isolated-data-2")).toHaveCount(0);
const serviceSet = state.serviceSets.find((set) => set.slug === "internal-isolated-stack");
expect(serviceSet.data_services.database.options.production_data_attached).toBe(false);
expect(serviceSet.data_services.redis.options.skip_replication_provisioning).toBe(true);
expect(serviceSet.data_services.minio.options.isolated_stack).toBe(true);
});
+101
View File
@@ -0,0 +1,101 @@
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { readJsonFile } from "./helpers/readJsonFile";
const root = process.cwd();
const activeLocales = ["da", "en", "sv", "de", "no"];
const channelKeys = ["stable", "canary", "internal"];
const suspiciousTranslationArtifact =
/(?:\p{L}\?\p{L}|(?:^|[\s([{])\?\p{L}|\p{L}\?@:\{|\u00c3|\u00c2|\ufffd|\u00ef\u00bf\u00bd)/u;
const isPlainObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
const mergeMessages = (sharedMessages = {}, localeMessages = {}) => {
const mergedMessages = { ...sharedMessages };
for (const [key, value] of Object.entries(localeMessages ?? {})) {
if (isPlainObject(value) && isPlainObject(mergedMessages[key])) {
mergedMessages[key] = mergeMessages(mergedMessages[key], value);
} else {
mergedMessages[key] = value;
}
}
return mergedMessages;
};
const flattenStrings = (value, prefix = "") => {
if (typeof value === "string") {
return [{ key: prefix, value }];
}
if (value === null || typeof value !== "object" || Array.isArray(value)) {
return [];
}
return Object.entries(value).flatMap(([key, nestedValue]) =>
flattenStrings(nestedValue, prefix ? `${prefix}.${key}` : key)
);
};
const loadReleaseManagerCatalogs = () =>
activeLocales.flatMap((locale) => {
const runtimeV1 = readJsonFile(join(root, `src/i18n/locales/${locale}.json`)).configuration.release_manager;
const source = readJsonFile(join(root, `src/i18n/source/${locale}/phrases/compat/configuration/index.json`)).compat
.configuration.release_manager;
const localeV2 = readJsonFile(join(root, `src/i18n/generated/${locale}-v2.json`));
const generatedV2 = localeV2.templates.generated.compat.configuration.release_manager;
const globalV2 = readJsonFile(join(root, "src/i18n/generated/global-v2.json"));
const runtimeV2 = mergeMessages(mergeMessages(globalV2.shared, globalV2.locales?.[locale]), localeV2).configuration
.release_manager;
return [
[`${locale} v1`, runtimeV1],
[`${locale} source`, source],
[`${locale} generated v2`, generatedV2],
[`${locale} runtime v2`, runtimeV2],
];
});
describe("release manager i18n", () => {
it("does not contain replacement-character translation artifacts", () => {
const failures = [];
for (const [name, releaseManager] of loadReleaseManagerCatalogs()) {
for (const { key, value } of flattenStrings(releaseManager)) {
if (suspiciousTranslationArtifact.test(value)) {
failures.push(`${name}: ${key} = ${value}`);
}
}
}
expect(failures).toEqual([]);
});
it("defines localized names and descriptions for built-in release channels", () => {
for (const [name, releaseManager] of loadReleaseManagerCatalogs()) {
expect(Object.keys(releaseManager.channel_names ?? {}), `${name} channel_names`).toEqual(channelKeys);
expect(Object.keys(releaseManager.channel_descriptions ?? {}), `${name} channel_descriptions`).toEqual(
channelKeys
);
for (const key of channelKeys) {
expect(releaseManager.channel_names[key], `${name} channel_names.${key}`).toEqual(expect.any(String));
expect(releaseManager.channel_descriptions[key], `${name} channel_descriptions.${key}`).toEqual(
expect.any(String)
);
}
}
});
it("uses Danish copy for the internal channel guard", () => {
const danishReleaseManager = readJsonFile(join(root, "src/i18n/locales/da.json")).configuration.release_manager;
expect(danishReleaseManager.channel_names.internal).toBe("Intern");
expect(danishReleaseManager.channel_descriptions.internal).toContain("Intern kanal");
expect(danishReleaseManager.channel_descriptions.internal).not.toBe(
"Internal staff and superuser validation channel."
);
});
});
+24
View File
@@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
__resetReleaseTimelineForTests,
__setReleaseTimelineTransportForTests,
buildReleaseTimelineContext,
configureReleaseRuntime,
flushReleaseTimelineEvents,
getRecentFrontendFailureEvents,
@@ -128,6 +129,29 @@ describe("release timeline runtime", () => {
expect(sentBodies).toHaveLength(1);
expect(sentBodies[0].context.channel_slug).toBe("beta");
expect(sentBodies[0].context.device.type).toBe("desktop");
expect(sentBodies[0].context.frontend.version_label).toBeTruthy();
expect(sentBodies[0].events[0].type).toBe("route_change");
});
it("builds device and release context for replay sessions", () => {
configureReleaseRuntime({
trace_id: "trace-context",
channel: { slug: "canary" },
versions: {
frontend: { version_label: "frontend-canary", commit_sha: "c0ffee" },
api: { version_label: "api-canary", commit_sha: "def456" },
},
capture_policy: { enabled: true, capture_level: "full_redacted" },
});
const context = buildReleaseTimelineContext();
expect(context.trace_id).toBe("trace-context");
expect(context.channel_slug).toBe("canary");
expect(context.device.type).toBe("desktop");
expect(context.frontend.version_label).toBe("frontend-canary");
expect(context.api.version_label).toBe("api-canary");
expect(context.viewport.width).toBeGreaterThan(0);
});
});