import {
serviceStatusLabelKind,
serviceWarmLabel,
dotenvFromVars,
parseDotenv,
dotenvErrorsMessage,
confirmEnvKey,
envRowsWithComments,
envValueFromDraft,
jobSummaryRows,
formatBytes,
} from "../format.js";
import { t } from "../i18n.js";
function primaryContainer(service) {
const list = service.containers || [];
if (list.length === 0) return { name: "—", id: "", idFull: "", nameFull: "", extra: "" };
let chosen = list[0];
for (const item of list) {
if (service.live_container_id && item.id === service.live_container_id) {
chosen = item;
break;
}
if (item.state === "running" && chosen.state !== "running") chosen = item;
}
const extra = list.length > 1 ? `+${list.length - 1}` : "";
return {
name: chosen.name || "—",
nameFull: chosen.name || "",
id: (chosen.id || "").slice(0, 12),
idFull: chosen.id || "",
extra,
};
}
document.addEventListener("alpine:init", () => {
Alpine.data("projectDetail", () => ({
get store() {
return Alpine.store("app");
},
editing: false,
editName: "",
editDescription: "",
projectTab: "services", revealedSecrets: {},
init() {
this.$watch(
() => this.store.selectedProjectId,
() => {
this.revealedSecrets = {};
}
);
},
get project() {
const store = this.store;
return ((store.snap && store.snap.projects) || []).find((project) => project.id === store.selectedProjectId) || null;
},
get services() {
const store = this.store;
const projectId = store.selectedProjectId;
const services = (store.snap && store.snap.services) || [];
const metricsById = store.metricsById || {};
const warmById = store.serviceWarmById || {};
return services
.filter((entry) => entry.service.spec.project_id === projectId)
.map((entry) => {
const service = entry.service;
const [label, kind] = serviceStatusLabelKind(service.status);
const metricsPoint = metricsById[service.id];
const container = primaryContainer(service);
const warm = warmById[service.id];
const [warmLabel, warmKind] = serviceWarmLabel(warm);
return {
hasWarm: !!warm,
warmLabel,
warmKind,
warmReason: warm?.reason || "",
id: service.id,
name: service.spec.name,
port: service.spec.port,
statusLabel: label,
statusKind: kind,
cpu: metricsPoint ? `${(metricsPoint.cpu_percent || 0).toFixed(1)}%` : "—",
mem: metricsPoint ? formatBytes(metricsPoint.mem_used_bytes) : "—",
containerName: container.name,
containerId: container.id,
containerNameFull: container.nameFull,
containerIdFull: container.idFull,
containerExtra: container.extra,
};
});
},
get canDelete() {
return this.services.length === 0;
},
get jobs() {
const projectId = this.store.selectedProjectId;
const list = (this.store.snap?.jobs || []).filter((job) => job.job.project_id === projectId);
return jobSummaryRows(list, "", this.store.jobsInflight);
},
startEdit() {
const project = this.project;
if (!project) return;
this.editName = project.name;
this.editDescription = project.description || "";
this.editing = true;
},
cancelEdit() {
this.editing = false;
},
async saveEdit() {
const updateProjectResult = await this.store.updateProject(this.store.selectedProjectId, this.editName, this.editDescription);
if (updateProjectResult.ok) this.editing = false;
},
newEnvKey: "",
newEnvValue: "",
envTextOpen: false,
envText: "",
get envVars() {
const project = this.project;
if (!project) return [];
return envRowsWithComments(project.env_vars, project.env_comments);
},
async addEnvVar() {
if (!this.newEnvKey.trim()) return;
if (!confirmEnvKey(this.store, this.newEnvKey.trim(), this.project.env_vars)) return;
const project = this.project;
const vars = (project.env_vars || []).filter((envVar) => envVar.key !== this.newEnvKey.trim());
vars.push({ key: this.newEnvKey.trim(), value: { Plain: this.newEnvValue } });
const saveProjectEnvResult = await this.store.saveProjectEnv(vars, project.env_comments || []);
if (saveProjectEnvResult.ok) {
this.newEnvKey = "";
this.newEnvValue = "";
}
},
async deleteEnvVar(key) {
const project = this.project;
const vars = (project.env_vars || []).filter((envVar) => envVar.key !== key);
const comments = (project.env_comments || []).filter((envComment) => envComment.before_key !== key);
await this.store.saveProjectEnv(vars, comments);
},
openEnvText() {
const project = this.project;
this.envText = dotenvFromVars(project?.env_vars, project?.env_comments);
this.envTextOpen = true;
},
closeEnvText() {
this.envTextOpen = false;
},
async saveEnvText() {
const { vars, comments, errors } = parseDotenv(this.envText);
if (errors.length) {
this.store.toastError(dotenvErrorsMessage(errors));
return;
}
const saveProjectEnvResult = await this.store.saveProjectEnv(vars, comments);
if (saveProjectEnvResult.ok) this.envTextOpen = false;
},
newSecretName: "",
newSecretValue: "",
get secrets() {
const project = this.project;
return (project?.secrets || []).map((name) => ({ name, value: this.revealedSecrets[name] ?? null }));
},
async revealSecret(name) {
if (!confirm(t`Ver o valor do secret "${name}"? O valor vai aparecer na tela e o acesso fica registrado na auditoria do servidor (quem, qual secret e quando; nunca o valor).`)) return;
const secretRevealResponse = await this.store.api.rpc({ SecretReveal: { project_id: this.store.selectedProjectId, name } });
const value = secretRevealResponse.ok ? secretRevealResponse.value?.SecretValue : undefined;
if (value === undefined) {
this.store.toastError(secretRevealResponse.error || t("não foi possível ler o secret"));
return;
}
this.revealedSecrets = { ...this.revealedSecrets, [name]: value };
},
hideSecret(name) {
const { [name]: _hidden, ...rest } = this.revealedSecrets;
this.revealedSecrets = rest;
},
copySecret(name) {
return this.store.copyText(this.revealedSecrets[name] ?? "", t("valor"));
},
envEdit: null,
startEnvEdit(row, field) {
this.envEdit = { key: row.key, field, draft: field === "key" ? row.key : row.isSecret ? row.secretName : row.value };
},
isEnvEditing(key, field) {
return !!this.envEdit && this.envEdit.key === key && this.envEdit.field === field;
},
cancelEnvEdit() {
this.envEdit = null;
},
async commitEnvEdit(row) {
const edit = this.envEdit;
if (!edit) return;
this.envEdit = null; const projectId = this.store.selectedProjectId;
if (edit.field === "key") {
const to = edit.draft.trim();
if (!to || to === row.key) return;
const renameResponse = await this.store.api.rpcChecked({ EnvKeyRename: { project_id: projectId, from: row.key, to } });
this.store.toastResult(renameResponse, t`renomeado: ${row.key} → ${to}`);
} else {
const current = row.isSecret ? row.secretName : row.value;
if (edit.draft === current) return;
const setResponse = await this.store.api.rpcChecked({
EnvValueSet: { project_id: projectId, key: row.key, value: envValueFromDraft(edit.draft, row.isSecret) },
});
this.store.toastResult(setResponse, t`valor de ${row.key} salvo`);
}
await this.store.refreshNow();
},
async projectRename(kind, from) {
const answer = prompt(t`Renomear "${from}" para:`, from);
const to = answer === null ? "" : answer.trim();
if (!to || to === from) return;
const body = { project_id: this.store.selectedProjectId, from, to };
const renameResponse = await this.store.api.rpcChecked(
kind === "secret" ? { SecretRename: body } : { EnvKeyRename: body },
);
this.store.toastResult(renameResponse, t`renomeado: ${from} → ${to}`);
if (renameResponse.ok) await this.store.refreshNow();
},
moveTargetServiceId: "",
get serviceOptions() {
const projectId = this.store.selectedProjectId;
return ((this.store.snap && this.store.snap.services) || [])
.filter((entry) => entry.service.spec.project_id === projectId)
.map((entry) => ({ id: entry.service.id, name: entry.service.spec.name }));
},
async projectMove(op, name, serviceId, doneMessage) {
const secretMoveResponse = await this.store.api.rpcChecked({
SecretMove: { project_id: this.store.selectedProjectId, service_id: serviceId || null, name, op },
});
this.store.toastResult(secretMoveResponse, doneMessage);
if (secretMoveResponse.ok) await this.store.refreshNow();
},
targetService() {
if (this.moveTargetServiceId) return this.moveTargetServiceId;
this.store.toastError(t("Escolha antes o serviço de destino, no seletor do topo."));
return null;
},
projectEnvToSecret(key) {
return this.projectMove("EnvToSecret", key, null, t`variável ${key} movida para secret`);
},
projectSecretToEnv(name) {
if (!confirm(t`O valor de "${name}" deixa de ser cifrado e passa a aparecer em claro na variável (e no estado versionado). Continuar?`)) return;
return this.projectMove("SecretToEnv", name, null, t`secret ${name} movido para variável`);
},
projectSecretToService(name) {
const serviceId = this.targetService();
if (serviceId) return this.projectMove("ProjectSecretToService", name, serviceId, t`secret ${name} movido para o serviço`);
},
projectEnvToService(key) {
const serviceId = this.targetService();
if (serviceId) return this.projectMove("EnvProjectToService", key, serviceId, t`variável ${key} movida para o serviço`);
},
async submitSecret() {
const addSecretResult = await this.store.addSecret(this.newSecretName, this.newSecretValue);
if (addSecretResult.ok) {
this.newSecretName = "";
this.newSecretValue = "";
}
},
}));
});