Redact serialized request bodies and omit failed-request bodies when capture is disabled
1009 lines
32 KiB
JavaScript
1009 lines
32 KiB
JavaScript
import { reactive, readonly } from "vue";
|
|
import { API_URL } from "@/config.js";
|
|
import { isTrustedReleaseUrl } from "@/services/releaseTrust.js";
|
|
import { buildReleaseHeaders, RELEASE_TRACE_STORAGE_KEY } from "@/services/releaseHeaders.js";
|
|
|
|
const TRACE_STORAGE_KEY = RELEASE_TRACE_STORAGE_KEY;
|
|
const MAX_QUEUE_SIZE = 50;
|
|
const MAX_FRONTEND_FAILURE_BUFFER_SIZE = 50;
|
|
const FRONTEND_FAILURE_EVENT_TYPES = new Set([
|
|
"vue_component_error",
|
|
"vue_component_warning",
|
|
"window_error",
|
|
"unhandled_rejection",
|
|
]);
|
|
const RELEASE_RUNTIME_SOURCES = new Set(["local", "deployment"]);
|
|
const RELEASE_RUNTIME_REQUESTED_SOURCES = new Set(["local", "deployment", "auto"]);
|
|
|
|
const normalizeRuntimeSource = (value) => {
|
|
const source = String(value || "").trim().toLowerCase();
|
|
return RELEASE_RUNTIME_SOURCES.has(source) ? source : "";
|
|
};
|
|
|
|
const normalizeRuntimeRequestedSource = (value) => {
|
|
const source = String(value || "").trim().toLowerCase();
|
|
return RELEASE_RUNTIME_REQUESTED_SOURCES.has(source) ? source : "";
|
|
};
|
|
|
|
const createTraceId = () => {
|
|
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
|
return crypto.randomUUID().replace(/-/g, "");
|
|
}
|
|
return `${Date.now().toString(16)}${Math.random().toString(16).slice(2)}`.slice(0, 32);
|
|
};
|
|
|
|
const readTraceId = () => {
|
|
if (typeof window === "undefined") {
|
|
return createTraceId();
|
|
}
|
|
|
|
try {
|
|
const stored = window.localStorage.getItem(TRACE_STORAGE_KEY);
|
|
if (stored) {
|
|
return stored;
|
|
}
|
|
const next = createTraceId();
|
|
window.localStorage.setItem(TRACE_STORAGE_KEY, next);
|
|
return next;
|
|
} catch {
|
|
return createTraceId();
|
|
}
|
|
};
|
|
|
|
const releaseRuntimeStateMutable = reactive({
|
|
source: null,
|
|
requestedSource: null,
|
|
traceId: readTraceId(),
|
|
channel: null,
|
|
availableChannels: [],
|
|
versions: {
|
|
frontend: null,
|
|
api: null,
|
|
service_set: null,
|
|
bundle_id: null,
|
|
bundle: null,
|
|
},
|
|
frontendBaseUrl: null,
|
|
apiBaseUrl: null,
|
|
availability: {
|
|
configured: true,
|
|
missing: [],
|
|
status: "ready",
|
|
explicit: false,
|
|
},
|
|
capturePolicy: {
|
|
enabled: false,
|
|
capture_level: "metadata",
|
|
all_failure_metadata: true,
|
|
retention_days: 14,
|
|
},
|
|
generatedAt: null,
|
|
});
|
|
|
|
let pendingEvents = [];
|
|
let recentFrontendFailureEvents = [];
|
|
let flushTimer = null;
|
|
let customTransport = null;
|
|
|
|
export const releaseRuntimeState = readonly(releaseRuntimeStateMutable);
|
|
|
|
const hasOwn = (value, key) => Boolean(value && Object.prototype.hasOwnProperty.call(value, key));
|
|
|
|
const normalizeReadinessMissingValues = (missing = []) =>
|
|
Array.from(
|
|
new Set(
|
|
(Array.isArray(missing) ? missing : [])
|
|
.map((value) => String(value || "").trim())
|
|
.filter((value) => value && value !== "release_bundle")
|
|
)
|
|
);
|
|
|
|
const normalizeRuntimeBaseUrl = (value, { allowRelative = true } = {}) => {
|
|
const raw = String(value || "").trim().replace(/\/+$/, "");
|
|
if (!raw || !isTrustedReleaseUrl(raw, { allowRelative })) {
|
|
return null;
|
|
}
|
|
if (raw.startsWith("/") && !raw.startsWith("//")) {
|
|
return raw;
|
|
}
|
|
if (/^https?:\/\//i.test(raw)) {
|
|
return raw;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const runtimeUrls = (runtime = {}) => {
|
|
const urls = runtime?.urls && typeof runtime.urls === "object" ? runtime.urls : {};
|
|
return {
|
|
frontendBaseUrl: normalizeRuntimeBaseUrl(runtime.frontend_base_url ?? urls.frontend_base_url, {
|
|
allowRelative: false,
|
|
}),
|
|
apiBaseUrl: normalizeRuntimeBaseUrl(runtime.api_base_url ?? urls.api_base_url),
|
|
};
|
|
};
|
|
|
|
const hasExplicitReleaseRuntime = (runtime) =>
|
|
Boolean(
|
|
runtime?.availability && typeof runtime.availability === "object" && runtime.availability.explicit !== false
|
|
) || hasOwn(runtime, "versions");
|
|
|
|
export const configureReleaseRuntime = (runtime = {}) => {
|
|
if (!runtime || typeof runtime !== "object") {
|
|
return releaseRuntimeStateMutable;
|
|
}
|
|
|
|
const runtimeSource = normalizeRuntimeSource(runtime.source);
|
|
if (runtimeSource) {
|
|
releaseRuntimeStateMutable.source = runtimeSource;
|
|
}
|
|
const requestedSource = normalizeRuntimeRequestedSource(runtime.requested_source || runtime.requestedSource);
|
|
if (requestedSource) {
|
|
releaseRuntimeStateMutable.requestedSource = requestedSource;
|
|
} else if (runtimeSource) {
|
|
releaseRuntimeStateMutable.requestedSource = runtimeSource;
|
|
}
|
|
|
|
if (runtime.trace_id) {
|
|
releaseRuntimeStateMutable.traceId = String(runtime.trace_id);
|
|
try {
|
|
window.localStorage.setItem(TRACE_STORAGE_KEY, releaseRuntimeStateMutable.traceId);
|
|
} catch {
|
|
// Storage is optional.
|
|
}
|
|
}
|
|
|
|
releaseRuntimeStateMutable.channel = runtime.channel || null;
|
|
releaseRuntimeStateMutable.availableChannels = Array.isArray(runtime.available_channels)
|
|
? runtime.available_channels
|
|
: Array.isArray(runtime.availableChannels)
|
|
? runtime.availableChannels
|
|
: [];
|
|
releaseRuntimeStateMutable.versions = {
|
|
frontend: runtime?.versions?.frontend || null,
|
|
api: runtime?.versions?.api || null,
|
|
service_set: runtime?.versions?.service_set || null,
|
|
bundle_id: runtime?.versions?.bundle_id || null,
|
|
bundle: runtime?.versions?.bundle || null,
|
|
};
|
|
const urls = runtimeUrls(runtime);
|
|
releaseRuntimeStateMutable.frontendBaseUrl = urls.frontendBaseUrl;
|
|
releaseRuntimeStateMutable.apiBaseUrl = urls.apiBaseUrl;
|
|
const channel = runtime.channel || null;
|
|
const isDefaultChannel =
|
|
channel?.default_channel === true || channel?.default_channel === 1 || String(channel?.slug || "") === "stable";
|
|
const missingReleaseContent = [];
|
|
if (!isDefaultChannel && hasOwn(runtime, "versions")) {
|
|
if (!runtime?.versions?.frontend) {
|
|
missingReleaseContent.push("frontend_version");
|
|
} else if (!urls.frontendBaseUrl) {
|
|
missingReleaseContent.push("frontend_base_url");
|
|
}
|
|
if (!runtime?.versions?.api) {
|
|
missingReleaseContent.push("api_version");
|
|
} else if (!urls.apiBaseUrl) {
|
|
missingReleaseContent.push("api_base_url");
|
|
}
|
|
}
|
|
releaseRuntimeStateMutable.availability = runtime.availability
|
|
? (() => {
|
|
const missing = normalizeReadinessMissingValues(runtime.availability.missing);
|
|
let configured = missing.length === 0;
|
|
if (!configured) {
|
|
configured = runtime.availability.configured !== false && runtime.availability.status !== "unconfigured";
|
|
}
|
|
const status =
|
|
configured && ["", "unconfigured", "missing_target"].includes(String(runtime.availability.status || ""))
|
|
? "ready"
|
|
: runtime.availability.status || (configured ? "ready" : "unconfigured");
|
|
return {
|
|
...runtime.availability,
|
|
configured,
|
|
missing,
|
|
status,
|
|
explicit: true,
|
|
};
|
|
})()
|
|
: hasExplicitReleaseRuntime(runtime)
|
|
? {
|
|
configured: missingReleaseContent.length === 0,
|
|
missing: missingReleaseContent,
|
|
status: missingReleaseContent.length === 0 ? "ready" : "unconfigured",
|
|
explicit: true,
|
|
}
|
|
: {
|
|
configured: true,
|
|
missing: [],
|
|
status: "ready",
|
|
explicit: false,
|
|
};
|
|
releaseRuntimeStateMutable.capturePolicy = {
|
|
enabled: Boolean(runtime?.capture_policy?.enabled),
|
|
capture_level: runtime?.capture_policy?.capture_level || "metadata",
|
|
all_failure_metadata: runtime?.capture_policy?.all_failure_metadata !== false,
|
|
retention_days: Number(runtime?.capture_policy?.retention_days || 14),
|
|
};
|
|
releaseRuntimeStateMutable.generatedAt = runtime.generated_at || new Date().toISOString();
|
|
return releaseRuntimeStateMutable;
|
|
};
|
|
|
|
export const getReleaseRuntimeApiBaseUrl = () => releaseRuntimeStateMutable.apiBaseUrl || API_URL;
|
|
|
|
export const resolveReleaseApiUrl = (url = "") => {
|
|
const value = String(url || "");
|
|
if (/^https?:\/\//i.test(value)) {
|
|
return rewriteReleaseApiUrl(value);
|
|
}
|
|
|
|
return `${getReleaseRuntimeApiBaseUrl().replace(/\/+$/, "")}/${value.replace(/^\/+/, "")}`;
|
|
};
|
|
|
|
export const rewriteReleaseApiUrl = (url = "") => {
|
|
const value = String(url || "");
|
|
const runtimeApiUrl = releaseRuntimeStateMutable.apiBaseUrl;
|
|
const defaultApiUrl = API_URL.replace(/\/+$/, "");
|
|
if (!runtimeApiUrl || runtimeApiUrl === defaultApiUrl || !value) {
|
|
return value;
|
|
}
|
|
|
|
if (value === defaultApiUrl) {
|
|
return runtimeApiUrl;
|
|
}
|
|
|
|
if (value.startsWith(`${defaultApiUrl}/`)) {
|
|
return `${runtimeApiUrl}${value.slice(defaultApiUrl.length)}`;
|
|
}
|
|
|
|
return value;
|
|
};
|
|
|
|
const SENSITIVE_PAYLOAD_KEY_PATTERN =
|
|
/authorization|cookie|password|passwd|secret|token|api[_-]?key|session|credential|card|cpr|ssn|recaptcha/i;
|
|
const CAPTURE_DISABLED_BODY_PLACEHOLDER = "[capture-disabled]";
|
|
|
|
const truncateReleasePayloadString = (value) =>
|
|
value.length > 4000 ? `${value.slice(0, 4000)}\n... [truncated]` : value;
|
|
|
|
const redactSerializedReleasePayload = (value, depth) => {
|
|
const trimmed = value.trim();
|
|
|
|
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
|
try {
|
|
return truncateReleasePayloadString(JSON.stringify(redactReleasePayload(JSON.parse(value), depth + 1)));
|
|
} catch {
|
|
// Fall through to form-encoded and truncation handling.
|
|
}
|
|
}
|
|
|
|
if (trimmed.includes("=") && !trimmed.includes("\n")) {
|
|
const params = new URLSearchParams(trimmed);
|
|
const entries = Array.from(params.entries());
|
|
if (entries.length > 0) {
|
|
let changed = false;
|
|
const redactedParams = new URLSearchParams();
|
|
for (const [key, item] of entries.slice(0, 80)) {
|
|
if (SENSITIVE_PAYLOAD_KEY_PATTERN.test(key)) {
|
|
redactedParams.append(key, "[redacted]");
|
|
changed = true;
|
|
} else {
|
|
redactedParams.append(key, truncateReleasePayloadString(item));
|
|
}
|
|
}
|
|
if (changed) {
|
|
return truncateReleasePayloadString(redactedParams.toString());
|
|
}
|
|
}
|
|
}
|
|
|
|
return truncateReleasePayloadString(value);
|
|
};
|
|
|
|
export const redactReleasePayload = (value, depth = 0) => {
|
|
if (depth > 8) {
|
|
return "[depth-limit]";
|
|
}
|
|
|
|
if (Array.isArray(value)) {
|
|
return value.slice(0, 80).map((item) => redactReleasePayload(item, depth + 1));
|
|
}
|
|
|
|
if (value && typeof value === "object") {
|
|
return Object.fromEntries(
|
|
Object.entries(value)
|
|
.slice(0, 80)
|
|
.map(([key, item]) => {
|
|
if (SENSITIVE_PAYLOAD_KEY_PATTERN.test(key)) {
|
|
return [key, "[redacted]"];
|
|
}
|
|
return [key, redactReleasePayload(item, depth + 1)];
|
|
})
|
|
);
|
|
}
|
|
|
|
if (typeof value === "string") {
|
|
return redactSerializedReleasePayload(value, depth);
|
|
}
|
|
|
|
return value;
|
|
};
|
|
|
|
const isFailureEvent = (type, severity) => {
|
|
const normalizedType = String(type || "").toLowerCase();
|
|
const normalizedSeverity = String(severity || "").toLowerCase();
|
|
return (
|
|
normalizedSeverity === "error" ||
|
|
normalizedSeverity === "warning" ||
|
|
normalizedType.includes("error") ||
|
|
normalizedType.includes("failed") ||
|
|
normalizedType.includes("failure")
|
|
);
|
|
};
|
|
|
|
const shouldSendEvent = (type, severity) => {
|
|
if (isFailureEvent(type, severity)) {
|
|
return true;
|
|
}
|
|
return releaseRuntimeStateMutable.capturePolicy.enabled === true;
|
|
};
|
|
|
|
const omitCapturedBodiesWhenDisabled = (payload) => {
|
|
if (!payload || typeof payload !== "object") {
|
|
return payload;
|
|
}
|
|
|
|
const nextPayload = { ...payload };
|
|
for (const key of ["request", "response"]) {
|
|
if (nextPayload[key] && typeof nextPayload[key] === "object" && hasOwn(nextPayload[key], "data")) {
|
|
nextPayload[key] = {
|
|
...nextPayload[key],
|
|
data: CAPTURE_DISABLED_BODY_PLACEHOLDER,
|
|
};
|
|
}
|
|
}
|
|
return nextPayload;
|
|
};
|
|
|
|
const sanitizeReleaseTimelinePayload = (type, severity, payload) => {
|
|
if (isFailureEvent(type, severity) && !releaseRuntimeStateMutable.capturePolicy.enabled) {
|
|
return omitCapturedBodiesWhenDisabled(payload);
|
|
}
|
|
return payload;
|
|
};
|
|
|
|
export const recordReleaseTimelineEvent = (type, payload = {}, options = {}) => {
|
|
const severity = options.severity || payload?.severity || "info";
|
|
if (!shouldSendEvent(type, severity)) {
|
|
return false;
|
|
}
|
|
|
|
const event = {
|
|
type,
|
|
severity,
|
|
module_key: options.moduleKey || payload?.module_key || null,
|
|
route: options.route || payload?.route || (typeof window !== "undefined" ? window.location.pathname : null),
|
|
component: options.component || payload?.component || null,
|
|
request_id: options.requestId || payload?.request_id || null,
|
|
occurred_at: new Date().toISOString(),
|
|
payload: redactReleasePayload(sanitizeReleaseTimelinePayload(type, severity, payload)),
|
|
};
|
|
|
|
if (FRONTEND_FAILURE_EVENT_TYPES.has(type)) {
|
|
recentFrontendFailureEvents = [event, ...recentFrontendFailureEvents].slice(0, MAX_FRONTEND_FAILURE_BUFFER_SIZE);
|
|
}
|
|
|
|
pendingEvents = [...pendingEvents, event].slice(-MAX_QUEUE_SIZE);
|
|
scheduleReleaseTimelineFlush();
|
|
return true;
|
|
};
|
|
|
|
export const getRecentFrontendFailureEvents = () =>
|
|
recentFrontendFailureEvents.map((event) => ({
|
|
...event,
|
|
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;
|
|
|
|
const RELEASE_SERVICE_DEFINITIONS = Object.freeze([
|
|
{ key: "frontend", label: "Frontend", kind: "app" },
|
|
{ key: "api", label: "API", kind: "app" },
|
|
{ key: "database", label: "Database", kind: "data" },
|
|
{ key: "redis", label: "Redis", kind: "data" },
|
|
{ key: "minio", label: "MinIO", kind: "data" },
|
|
]);
|
|
|
|
const RELEASE_MISSING_LABELS = Object.freeze({
|
|
release_bundle: "Release bundle",
|
|
frontend_version: "Frontend version",
|
|
frontend_base_url: "Frontend URL",
|
|
api_version: "API version",
|
|
api_base_url: "API URL",
|
|
database_service: "Database service",
|
|
redis_service: "Redis service",
|
|
minio_service: "MinIO service",
|
|
});
|
|
|
|
const isPlainRecord = (value) => Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
|
|
const firstFilledString = (...values) => {
|
|
for (const value of values) {
|
|
const normalized = String(value ?? "").trim();
|
|
if (normalized) {
|
|
return normalized;
|
|
}
|
|
}
|
|
return "";
|
|
};
|
|
|
|
const releaseRuntimeUrlsForDisplay = (runtime = {}) => {
|
|
const urls = isPlainRecord(runtime?.urls) ? runtime.urls : {};
|
|
return {
|
|
frontend: normalizeRuntimeBaseUrl(runtime.frontendBaseUrl ?? runtime.frontend_base_url ?? urls.frontend_base_url, {
|
|
allowRelative: false,
|
|
}),
|
|
api: normalizeRuntimeBaseUrl(runtime.apiBaseUrl ?? runtime.api_base_url ?? urls.api_base_url),
|
|
};
|
|
};
|
|
|
|
const releaseRuntimeTraceId = (runtime = {}) => firstFilledString(runtime.traceId, runtime.trace_id);
|
|
|
|
const releaseRuntimeGeneratedAt = (runtime = {}) => firstFilledString(runtime.generatedAt, runtime.generated_at);
|
|
|
|
const releaseShortText = (value, length = 12) => {
|
|
const normalized = firstFilledString(value);
|
|
if (!normalized) {
|
|
return "";
|
|
}
|
|
return normalized.length > length ? normalized.slice(0, length) : normalized;
|
|
};
|
|
|
|
const releaseCommitValue = (version = null) => {
|
|
if (!isPlainRecord(version)) {
|
|
return "";
|
|
}
|
|
|
|
const commit = version.commit;
|
|
if (isPlainRecord(commit)) {
|
|
return firstFilledString(commit.sha, commit.commit_sha);
|
|
}
|
|
return firstFilledString(version.commit_sha, commit);
|
|
};
|
|
|
|
const releaseVersionPrimaryText = (version = null, fallback = "Missing version") => {
|
|
if (!isPlainRecord(version)) {
|
|
return fallback;
|
|
}
|
|
|
|
return firstFilledString(version.version_label, version.tag, releaseShortText(releaseCommitValue(version)), fallback);
|
|
};
|
|
|
|
const releaseVersionSecondaryText = (version = null) => {
|
|
if (!isPlainRecord(version)) {
|
|
return "";
|
|
}
|
|
|
|
const parts = [];
|
|
const commit = releaseShortText(releaseCommitValue(version));
|
|
const repository = firstFilledString(version.repository);
|
|
const branch = firstFilledString(version.branch);
|
|
|
|
if (commit && commit !== firstFilledString(version.version_label, version.tag)) {
|
|
parts.push(commit);
|
|
}
|
|
if (repository || branch) {
|
|
parts.push(branch ? `${repository || "repository"}#${branch}` : repository);
|
|
}
|
|
return parts.join(" - ");
|
|
};
|
|
|
|
const releaseStatusTone = (status = "") => {
|
|
const normalized = String(status || "").trim().toLowerCase();
|
|
if (
|
|
[
|
|
"failed",
|
|
"error",
|
|
"critical",
|
|
"service_unhealthy",
|
|
"unhealthy",
|
|
"degraded",
|
|
"reconcile_failed",
|
|
"restart_failed",
|
|
"provision_blocked",
|
|
].includes(normalized)
|
|
) {
|
|
return "danger";
|
|
}
|
|
if (
|
|
[
|
|
"missing",
|
|
"missing_value",
|
|
"not_configured",
|
|
"unconfigured",
|
|
"deployment_in_progress",
|
|
"warning",
|
|
"pending",
|
|
"queued",
|
|
"running",
|
|
"deploying",
|
|
"building",
|
|
"provisioning",
|
|
"unknown",
|
|
].includes(normalized)
|
|
) {
|
|
return "warning";
|
|
}
|
|
return "ok";
|
|
};
|
|
|
|
const releaseServiceStatus = (service = null, fallback = "connected") =>
|
|
firstFilledString(
|
|
service?.deployment_status,
|
|
service?.availability_state,
|
|
service?.status,
|
|
service?.state,
|
|
fallback
|
|
);
|
|
|
|
const releaseServicePrimaryText = (service = null) => {
|
|
if (!isPlainRecord(service)) {
|
|
return "";
|
|
}
|
|
|
|
const name = firstFilledString(
|
|
service.resource_name,
|
|
service.label,
|
|
service.coolify_service_uuid,
|
|
service.health_url,
|
|
service.repository
|
|
);
|
|
const id = Number(service.id || service.target_id || 0);
|
|
return [name, id > 0 ? `#${id}` : ""].filter(Boolean).join(" ");
|
|
};
|
|
|
|
const releaseServiceSecondaryText = (service = null) => {
|
|
if (!isPlainRecord(service)) {
|
|
return "";
|
|
}
|
|
|
|
const parts = [];
|
|
const resourceUuid = firstFilledString(service.resource_uuid);
|
|
const coolifyServiceUuid = firstFilledString(service.coolify_service_uuid);
|
|
const instanceLabel = firstFilledString(service.instance_label, service.coolify_instance_label);
|
|
const repository = firstFilledString(service.repository);
|
|
const branch = firstFilledString(service.branch);
|
|
const replication = isPlainRecord(service.replication) ? service.replication : null;
|
|
const replicationStatus = firstFilledString(replication?.last_status?.status, replication?.status);
|
|
|
|
if (resourceUuid) {
|
|
parts.push(`resource ${releaseShortText(resourceUuid)}`);
|
|
}
|
|
if (coolifyServiceUuid && coolifyServiceUuid !== resourceUuid) {
|
|
parts.push(`service ${releaseShortText(coolifyServiceUuid)}`);
|
|
}
|
|
if (repository || branch) {
|
|
parts.push(branch ? `${repository || "repository"}#${branch}` : repository);
|
|
}
|
|
if (instanceLabel) {
|
|
parts.push(instanceLabel);
|
|
}
|
|
if (replicationStatus) {
|
|
parts.push(`replication ${replicationStatus}`);
|
|
}
|
|
return parts.join(" - ");
|
|
};
|
|
|
|
const releaseServiceForKey = (serviceSet = null, key = "") => {
|
|
if (!isPlainRecord(serviceSet)) {
|
|
return null;
|
|
}
|
|
|
|
const stack = isPlainRecord(serviceSet.stack) ? serviceSet.stack : {};
|
|
const dataServices = isPlainRecord(serviceSet.data_services) ? serviceSet.data_services : {};
|
|
const targets = isPlainRecord(serviceSet.targets) ? serviceSet.targets : {};
|
|
const service = stack[key] || dataServices[key] || targets[key] || null;
|
|
return isPlainRecord(service) ? service : null;
|
|
};
|
|
|
|
export const buildReleaseSessionSummary = (runtime = releaseRuntimeStateMutable, options = {}) => {
|
|
const versions = isPlainRecord(runtime?.versions) ? runtime.versions : {};
|
|
const channel = isPlainRecord(runtime?.channel) ? runtime.channel : null;
|
|
const availability = isPlainRecord(runtime?.availability) ? runtime.availability : {};
|
|
const missing = normalizeReadinessMissingValues(availability.missing);
|
|
const missingLookup = new Set(missing);
|
|
const isDefaultChannel =
|
|
channel?.default_channel === true
|
|
|| channel?.default_channel === 1
|
|
|| String(channel?.slug || "").toLowerCase() === "stable";
|
|
const includeInfrastructureDetails = options?.includeInfrastructureDetails === true;
|
|
const urls = includeInfrastructureDetails ? releaseRuntimeUrlsForDisplay(runtime) : { frontend: "", api: "" };
|
|
const frontendVersion = isPlainRecord(versions.frontend) ? versions.frontend : null;
|
|
const apiVersion = isPlainRecord(versions.api) ? versions.api : null;
|
|
const bundle = isPlainRecord(versions.bundle) ? versions.bundle : null;
|
|
const bundleId = versions.bundle_id || bundle?.id || null;
|
|
const rawServiceSet = isPlainRecord(versions.service_set)
|
|
? versions.service_set
|
|
: isPlainRecord(bundle?.service_set)
|
|
? bundle.service_set
|
|
: null;
|
|
const serviceSet = includeInfrastructureDetails ? rawServiceSet : null;
|
|
const defaultSharedLabel = "Default/shared runtime";
|
|
const missingLabels = missing.map((key) => RELEASE_MISSING_LABELS[key] || key.replace(/_/g, " "));
|
|
|
|
const buildAppRow = (key, label, version, url) => {
|
|
const missingVersionKey = `${key}_version`;
|
|
const missingUrlKey = `${key}_base_url`;
|
|
const missingKey = missingLookup.has(missingVersionKey)
|
|
? missingVersionKey
|
|
: missingLookup.has(missingUrlKey)
|
|
? missingUrlKey
|
|
: "";
|
|
const fallback = isDefaultChannel ? defaultSharedLabel : `Missing ${label} version`;
|
|
const status = missingKey
|
|
? "missing value"
|
|
: isDefaultChannel && !version && !url
|
|
? "shared"
|
|
: firstFilledString(version?.status, url ? "active" : "unknown");
|
|
|
|
const primaryText = releaseVersionPrimaryText(version, fallback);
|
|
const secondaryText = includeInfrastructureDetails ? releaseVersionSecondaryText(version) : "";
|
|
const displayUrl = includeInfrastructureDetails ? url || "" : "";
|
|
|
|
return {
|
|
key,
|
|
label,
|
|
status,
|
|
tone: missingKey ? "warning" : releaseStatusTone(status),
|
|
primaryText,
|
|
secondaryText,
|
|
url: displayUrl,
|
|
title: [primaryText, secondaryText, displayUrl]
|
|
.filter(Boolean)
|
|
.join(" - "),
|
|
missingLabel: missingKey ? RELEASE_MISSING_LABELS[missingKey] || missingKey : "",
|
|
};
|
|
};
|
|
|
|
const buildServiceRow = ({ key, label }) => {
|
|
const service = releaseServiceForKey(serviceSet, key);
|
|
const missingKey = missingLookup.has(`${key}_service`) ? `${key}_service` : "";
|
|
if (service) {
|
|
const status = releaseServiceStatus(service);
|
|
return {
|
|
key,
|
|
label,
|
|
status,
|
|
tone: releaseStatusTone(status),
|
|
primaryText: releaseServicePrimaryText(service) || "Connected service",
|
|
secondaryText: releaseServiceSecondaryText(service),
|
|
title: [releaseServicePrimaryText(service), releaseServiceSecondaryText(service), firstFilledString(service.health_url)]
|
|
.filter(Boolean)
|
|
.join(" - "),
|
|
missingLabel: "",
|
|
};
|
|
}
|
|
|
|
const fallbackText = missingKey ? `Missing ${label} service` : defaultSharedLabel;
|
|
const status = missingKey ? "missing" : "shared";
|
|
|
|
return {
|
|
key,
|
|
label,
|
|
status,
|
|
tone: missingKey ? "warning" : "ok",
|
|
primaryText: fallbackText,
|
|
secondaryText: "",
|
|
title: fallbackText,
|
|
missingLabel: missingKey ? RELEASE_MISSING_LABELS[missingKey] || missingKey : "",
|
|
};
|
|
};
|
|
|
|
return {
|
|
channelLabel: firstFilledString(channel?.name, channel?.slug, isDefaultChannel ? "Stable" : "Unknown channel"),
|
|
channelSlug: firstFilledString(channel?.slug),
|
|
traceId: releaseRuntimeTraceId(runtime) || "unknown",
|
|
generatedAt: releaseRuntimeGeneratedAt(runtime) || "unknown",
|
|
availabilityStatus: firstFilledString(availability.status, availability.configured === false ? "unconfigured" : "ready"),
|
|
availabilityTone: availability.configured === false || missing.length > 0
|
|
? "warning"
|
|
: releaseStatusTone(availability.status || "ready"),
|
|
bundleLabel: bundleId
|
|
? `#${bundleId}${firstFilledString(bundle?.version_label) ? ` ${bundle.version_label}` : ""}`
|
|
: defaultSharedLabel,
|
|
bundleStatus: firstFilledString(bundle?.status, bundleId ? "active" : "shared"),
|
|
serviceSetLabel: serviceSet
|
|
? firstFilledString(serviceSet.name, serviceSet.slug, serviceSet.id ? `#${serviceSet.id}` : "Connected")
|
|
: rawServiceSet
|
|
? "Restricted to release operators"
|
|
: defaultSharedLabel,
|
|
missingLabels,
|
|
appRows: [
|
|
buildAppRow("frontend", "Frontend", frontendVersion, urls.frontend),
|
|
buildAppRow("api", "API", apiVersion, urls.api),
|
|
],
|
|
serviceRows: includeInfrastructureDetails || !rawServiceSet ? RELEASE_SERVICE_DEFINITIONS.map(buildServiceRow) : [],
|
|
};
|
|
};
|
|
|
|
export const buildCurrentReleaseHeaders = () => {
|
|
const frontendVersion = releaseRuntimeStateMutable.versions?.frontend || {};
|
|
const fallbackFrontendVersion = import.meta.env.VITE_COMMIT_HASH || import.meta.env.VITE_APP_VERSION || "unknown";
|
|
|
|
return buildReleaseHeaders({
|
|
traceId: releaseRuntimeStateMutable.traceId,
|
|
channelSlug: releaseRuntimeStateMutable.channel?.slug || "",
|
|
frontendVersion: commitSha(frontendVersion) || versionLabel(frontendVersion) || fallbackFrontendVersion,
|
|
});
|
|
};
|
|
|
|
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,
|
|
source: releaseRuntimeStateMutable.source,
|
|
requested_source: releaseRuntimeStateMutable.requestedSource,
|
|
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;
|
|
}
|
|
|
|
flushTimer = window.setTimeout(() => {
|
|
flushTimer = null;
|
|
void flushReleaseTimelineEvents();
|
|
}, 250);
|
|
};
|
|
|
|
export const flushReleaseTimelineEvents = async () => {
|
|
if (pendingEvents.length === 0) {
|
|
return { accepted: 0 };
|
|
}
|
|
|
|
const events = pendingEvents;
|
|
pendingEvents = [];
|
|
|
|
const body = {
|
|
events,
|
|
context: buildReleaseTimelineContext(),
|
|
};
|
|
|
|
try {
|
|
if (typeof customTransport === "function") {
|
|
return await customTransport(body);
|
|
}
|
|
|
|
const headers = {
|
|
"Content-Type": "application/json",
|
|
...buildCurrentReleaseHeaders(),
|
|
};
|
|
const token = typeof window !== "undefined" ? window.localStorage.getItem("token") : null;
|
|
if (token) {
|
|
headers.Authorization = `Bearer ${token}`;
|
|
}
|
|
|
|
const response = await fetch(resolveReleaseApiUrl("/release/timeline/events"), {
|
|
method: "POST",
|
|
headers,
|
|
body: JSON.stringify(body),
|
|
keepalive: true,
|
|
});
|
|
return response.ok ? response.json().catch(() => ({ accepted: events.length })) : { accepted: 0 };
|
|
} catch {
|
|
pendingEvents = [...events, ...pendingEvents].slice(-MAX_QUEUE_SIZE);
|
|
return { accepted: 0 };
|
|
}
|
|
};
|
|
|
|
export const installReleaseErrorInstrumentation = (app, router) => {
|
|
const previousErrorHandler = app?.config?.errorHandler;
|
|
const previousWarnHandler = app?.config?.warnHandler;
|
|
|
|
if (app?.config) {
|
|
app.config.errorHandler = (error, instance, info) => {
|
|
recordReleaseTimelineEvent(
|
|
"vue_component_error",
|
|
{
|
|
message: error?.message || String(error),
|
|
stack: error?.stack || null,
|
|
info,
|
|
component: instance?.type?.name || instance?.type?.__name || null,
|
|
},
|
|
{
|
|
severity: "error",
|
|
moduleKey: "frontend",
|
|
component: instance?.type?.name || instance?.type?.__name || null,
|
|
}
|
|
);
|
|
|
|
if (typeof previousErrorHandler === "function") {
|
|
previousErrorHandler(error, instance, info);
|
|
} else {
|
|
console.error(error);
|
|
}
|
|
};
|
|
|
|
app.config.warnHandler = (message, instance, trace) => {
|
|
if (releaseRuntimeStateMutable.capturePolicy.enabled) {
|
|
recordReleaseTimelineEvent(
|
|
"vue_component_warning",
|
|
{
|
|
message,
|
|
trace,
|
|
component: instance?.type?.name || instance?.type?.__name || null,
|
|
},
|
|
{
|
|
severity: "warning",
|
|
moduleKey: "frontend",
|
|
component: instance?.type?.name || instance?.type?.__name || null,
|
|
}
|
|
);
|
|
}
|
|
|
|
if (typeof previousWarnHandler === "function") {
|
|
previousWarnHandler(message, instance, trace);
|
|
}
|
|
};
|
|
}
|
|
|
|
if (typeof window !== "undefined") {
|
|
window.addEventListener("error", (event) => {
|
|
recordReleaseTimelineEvent(
|
|
"window_error",
|
|
{
|
|
message: event.message,
|
|
filename: event.filename,
|
|
lineno: event.lineno,
|
|
colno: event.colno,
|
|
stack: event.error?.stack || null,
|
|
},
|
|
{ severity: "error", moduleKey: "frontend" }
|
|
);
|
|
});
|
|
|
|
window.addEventListener("unhandledrejection", (event) => {
|
|
recordReleaseTimelineEvent(
|
|
"unhandled_rejection",
|
|
{
|
|
message: event.reason?.message || String(event.reason),
|
|
stack: event.reason?.stack || null,
|
|
},
|
|
{ severity: "error", moduleKey: "frontend" }
|
|
);
|
|
});
|
|
}
|
|
|
|
if (router?.afterEach) {
|
|
router.afterEach((to, from) => {
|
|
if (!releaseRuntimeStateMutable.capturePolicy.enabled) {
|
|
return;
|
|
}
|
|
recordReleaseTimelineEvent(
|
|
"route_change",
|
|
{
|
|
from: from?.fullPath || null,
|
|
to: to?.fullPath || null,
|
|
},
|
|
{ severity: "info", moduleKey: "frontend", route: to?.fullPath || null }
|
|
);
|
|
});
|
|
}
|
|
};
|
|
|
|
export const __resetReleaseTimelineForTests = () => {
|
|
pendingEvents = [];
|
|
recentFrontendFailureEvents = [];
|
|
if (flushTimer !== null && typeof window !== "undefined") {
|
|
window.clearTimeout(flushTimer);
|
|
}
|
|
flushTimer = null;
|
|
customTransport = null;
|
|
releaseRuntimeStateMutable.source = null;
|
|
releaseRuntimeStateMutable.requestedSource = null;
|
|
releaseRuntimeStateMutable.traceId = "test-trace";
|
|
releaseRuntimeStateMutable.channel = null;
|
|
releaseRuntimeStateMutable.availableChannels = [];
|
|
releaseRuntimeStateMutable.versions = { frontend: null, api: null, service_set: null, bundle_id: null, bundle: null };
|
|
releaseRuntimeStateMutable.frontendBaseUrl = null;
|
|
releaseRuntimeStateMutable.apiBaseUrl = null;
|
|
releaseRuntimeStateMutable.availability = {
|
|
configured: true,
|
|
missing: [],
|
|
status: "ready",
|
|
explicit: false,
|
|
};
|
|
releaseRuntimeStateMutable.capturePolicy = {
|
|
enabled: false,
|
|
capture_level: "metadata",
|
|
all_failure_metadata: true,
|
|
retention_days: 14,
|
|
};
|
|
releaseRuntimeStateMutable.generatedAt = null;
|
|
};
|
|
|
|
export const __setReleaseTimelineTransportForTests = (transport) => {
|
|
customTransport = transport;
|
|
};
|