import "./screens/login.js";
import "./screens/dashboard.js";
import "./screens/deploy_engine.js";
import "./screens/monitoring.js";
import "./screens/ingress.js";
import "./screens/docker.js";
import "./screens/schedules.js";
import "./screens/settings.js";
import "./screens/projects.js";
import "./screens/project_detail.js";
import "./screens/new_service.js";
import "./screens/service_detail.js";
import "./screens/service_bundle.js";
import Alpine from "https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/module.esm.js";
import { Api, decodeAccessKey, encodeAccessKey } from "./net/api.js";
import { openStream } from "./net/sse.js";
import { registerDirectives } from "./directives.js";
import { registerWindows } from "./wm.js";
import { registerIcons } from "./icons.js";
import { registerBusy } from "./busy.js";
import { buildManifestZip, readManifestZip } from "./zip.js";
import {
formatUptime,
formatBytes,
stripAnsi,
oauthRedirectUri,
timeHms,
dateDayMonthHourMinuteSecond,
dateDayMonthHourMinute,
parseDotenv,
dotenvFromVars,
dockerCleanupLastRunSummary,
shortReason,
hourMinuteJoin,
hourMinuteSplit,
jobSummaryRows,
retentionHours,
} from "./format.js";
window.Alpine = Alpine;
registerDirectives(Alpine);
registerWindows(Alpine);
registerIcons(Alpine);
registerBusy(Alpine);
const PREFS_KEY = "rustploy.prefs";
function loadPrefs() {
try {
return JSON.parse(localStorage.getItem(PREFS_KEY) || "{}");
} catch {
return {};
}
}
function savePrefs(preferences) {
localStorage.setItem(PREFS_KEY, JSON.stringify(preferences));
}
document.addEventListener("alpine:init", () => {
const prefs = loadPrefs();
Alpine.store("app", {
screen: "login",
view: "deployments",
menuOpen: true,
connected: false,
statusLine: "pronto para conectar",
error: "",
accessKey: prefs.rememberAccessKey && prefs.accessKey ? prefs.accessKey : "",
rememberAccessKey: !!prefs.rememberAccessKey,
serverAccessKey: "",
serverMcpCommand: "",
api: null,
stream: null,
search: "",
snap: null,
dataLoading: true,
daemonVersion: "",
daemonUptime: "…",
servicesLabel: "…",
deploymentsMessage: "",
metricsById: {}, hostCpu: "—",
hostMemory: "—",
hostDisk: "—",
hostLoad: "—",
dockerTab: "containers",
onlyUsedImages: false,
onlyUsedVolumes: false,
onlyUsedNetworks: false,
pruneAllImages: false,
pruneAllVolumes: false,
registrySelectedRepo: "",
registryTags: [],
registryTagsLoading: false,
registryTokens: [],
jobsById: {}, jobLogLines: [],
jobLogStream: null,
jobsInflight: {},
showNewJob: false,
jobActionsJobId: "",
projectWin: null,
showNewService: false,
exportWin: null, logWin: false, newJobStep: "pick_project", newJobEditId: null,
newJobEnabled: true,
newJobProjectId: "",
newJobProjectName: "",
newJobServiceId: "",
newJobServiceName: "",
newJobName: "",
newJobCompose: "",
newJobSourceTab: "compose",
newJobGitProviderId: "",
newJobGitProviders: [],
newJobGitRepos: [],
newJobGitRepoFullName: "",
newJobGitBranches: [],
newJobGitBranch: "",
newJobComposePath: "docker-compose.yml",
newJobGitMessage: "",
newJobMainService: "",
newJobEnvText: "",
newJobKind: "manual", newJobHours: "6",
newJobTime: "03:00",
newJobWeekday: "0",
newJobError: "",
newJobSubmitting: false,
jobLogsFor: null,
settingsTab: "web",
serverSettingsPublicBase: "",
serverSettingsEmail: "",
serverSettingsRegistryDomain: "",
settingsMessage: "",
gitProviders: [],
gitProviderKind: "gitea", gitProviderMode: "oauth", gitProviderName: "",
gitProviderBaseUrl: "",
gitProviderClientId: "",
gitProviderClientSecret: "",
gitProviderPersonalAccessToken: "",
gitProviderMessage: "",
gitProviderOauthUrl: "",
manifestYaml: "",
manifestDotenv: "",
manifestHasExport: false,
manifestExportMessage: "",
manifestImportYaml: "",
manifestImportDotenv: "",
manifestZipName: "", manifestPrune: false,
manifestDeploy: false,
manifestHasMissing: false,
manifestMissingVars: "",
manifestHasReport: false,
manifestReportLines: [],
manifestImportMessage: "",
dockerCleanupEnabled: false,
dockerCleanupKind: "daily", dockerCleanupHours: "6",
dockerCleanupTime: "03:00",
dockerCleanupWeekday: "0",
dockerCleanupContainers: false,
dockerCleanupImages: false,
dockerCleanupImagesAll: false,
dockerCleanupVolumes: false,
dockerCleanupVolumesAll: false,
dockerCleanupNetworks: false,
dockerCleanupBuildCache: false,
dockerCleanupBuildCacheOlderThanHours: "168",
dockerCleanupImagesOlderThanHours: "168",
dockerCleanupNextRunLabel: "—",
dockerCleanupLastRunAtRaw: null,
dockerCleanupLastRunText: "ainda não rodou",
dockerCleanupRunning: false,
dockerCleanupMessage: "",
get gitProviderRedirect() {
return oauthRedirectUri(this.serverSettingsPublicBase, this.gitProviderKind);
},
view: "deployments",
selectedProjectId: null,
selectedServiceId: null,
deployTrackId: null,
serviceDetail: null, serviceDeployments: [], serviceTab: "general",
serviceMessage: "",
serviceLoading: false,
serviceLogLines: [],
serviceLogStream: null,
toasts: [],
toastSeq: 0,
toast(message, kind = "info", durationMs = 4000) {
if (!message) return null;
const toastId = ++this.toastSeq;
this.toasts.push({ id: toastId, kind, message: String(message), ms: durationMs });
setTimeout(() => this.dismissToast(toastId), durationMs);
return toastId;
},
async copyText(text, what) {
try {
await navigator.clipboard.writeText(text);
this.toastOk(`${what} copiado`);
} catch (e) {
this.toastError(`não foi possível copiar o ${what}`);
}
},
toastOk(message) {
return this.toast(message, "success");
},
toastError(message) {
return this.toast(message, "error");
},
toastWarn(message) {
return this.toast(message, "warning");
},
toastResult(result, okMessage) {
if (result.ok) {
if (okMessage) this.toastOk(okMessage);
} else {
this.toastError("erro: " + result.error);
}
return result.ok;
},
dismissToast(toastId) {
const toastIndex = this.toasts.findIndex((toast) => toast.id === toastId);
if (toastIndex !== -1) this.toasts.splice(toastIndex, 1);
},
serviceNameById(serviceId) {
const entry = (this.snap?.services || []).find((service) => service.service.id === serviceId);
return entry?.service?.spec?.name || "serviço";
},
nav(view) {
this.stopServiceLogs();
this.view = view;
},
persistPrefs() {
savePrefs({
rememberAccessKey: this.rememberAccessKey,
accessKey: this.rememberAccessKey ? this.accessKey : undefined,
});
},
async connect() {
const base = window.location.origin;
this.persistPrefs();
this.statusLine = "conectando…";
this.error = "";
const decodedKey = decodeAccessKey(this.accessKey);
if (!decodedKey) {
this.error = "isso não é um token de conexão — cole o token de conexão inteiro (base64 de host:porta::token), não só o token da API";
this.statusLine = "falha na conexão";
this.connected = false;
return;
}
const client = new Api(base, decodedKey.token);
const daemonStatusResponse = await client.rpc("DaemonStatus");
if (!daemonStatusResponse.ok) {
this.error = daemonStatusResponse.error;
this.statusLine = "falha na conexão";
this.connected = false;
return;
}
this.api = client;
this.connected = true;
this.statusLine = "conectado";
this.screen = "shell";
this.dataLoading = true;
await this.loadSettings();
this.openStream();
},
disconnect() {
this.stopServiceLogs();
this.stream?.close();
this.stream = null;
this.api = null;
this.snap = null;
this.connected = false;
this.screen = "login";
this.statusLine = "desconectado";
this.view = "deployments";
this.selectedProjectId = null;
this.selectedServiceId = null;
this.serviceDetail = null;
},
openStream() {
this.stream?.close();
this.stream = openStream(this.api.baseUrl, this.api.token, "/api/events", {
onEvent: (kind, data) => this.onStreamEvent(data),
onError: (message) => {
this.statusLine = "stream: " + message;
},
onClose: () => {
this.stream = null;
if (this.connected) {
this.statusLine = "conexão encerrada";
this.connected = false;
this.screen = "login";
}
},
});
},
onStreamEvent(streamMessage) {
if (!streamMessage || typeof streamMessage !== "object") return;
if (streamMessage.kind === "snapshot") this.applySnapshot(streamMessage);
else if (streamMessage.kind === "bus") this.applyBusEvent(streamMessage.event);
},
applyBusEvent(busEvent) {
if (busEvent === "DeployQueueChanged") {
this.refreshNow();
return;
}
if (!busEvent || typeof busEvent !== "object") return;
if (busEvent.JobWarmChanged) {
const warmEvent = busEvent.JobWarmChanged;
if (!warmEvent.warming) {
const warmedJobName = this.jobsById[warmEvent.job_id]?.name || "job";
if (warmEvent.success) this.toastOk(`${warmedJobName} aquecido`);
else this.toastError(`${warmedJobName}: falha ao aquecer${warmEvent.error ? ` — ${warmEvent.error}` : ""}`);
}
this.refreshNow();
} else if (busEvent.DockerCleanupCompleted) {
const cleanupEvent = busEvent.DockerCleanupCompleted;
this.dockerCleanupRunning = false;
this.dockerCleanupLastRunText = dockerCleanupLastRunSummary({ at: cleanupEvent.at, results: cleanupEvent.results });
this.dockerCleanupLastRunAtRaw = cleanupEvent.at;
} else if (busEvent.ContainerMetrics) {
const containerMetrics = busEvent.ContainerMetrics;
if (containerMetrics.service_id) this.metricsById[containerMetrics.service_id] = containerMetrics;
} else if (busEvent.SystemMetrics) {
const systemMetrics = busEvent.SystemMetrics;
this.hostCpu = `${(systemMetrics.cpu_percent || 0).toFixed(0)}%`;
this.hostMemory = `${formatBytes(systemMetrics.mem_used_bytes)} / ${formatBytes(systemMetrics.mem_total_bytes)}`;
this.hostDisk = `${formatBytes(systemMetrics.disk_used_bytes)} / ${formatBytes(systemMetrics.disk_total_bytes)}`;
this.hostLoad = `${(systemMetrics.load_avg_1 || 0).toFixed(2)} ${(systemMetrics.load_avg_5 || 0).toFixed(2)} ${(systemMetrics.load_avg_15 || 0).toFixed(2)}`;
} else if (busEvent.DeployStateChanged) {
const deployEvent = busEvent.DeployStateChanged;
const terminal =
deployEvent.state === "Live" || deployEvent.state === "Failed" || deployEvent.state === "Stopped";
const motivo = shortReason(deployEvent.message);
if (deployEvent.service_id && deployEvent.service_id === this.selectedServiceId) {
if (terminal) {
this.serviceMessage =
deployEvent.state === "Live"
? "deploy concluído"
: motivo
? `deploy falhou: ${motivo}`
: `deploy: ${deployEvent.state}`;
this.fetchServiceDetail(deployEvent.service_id);
} else {
this.serviceMessage = `deploy · ${deployEvent.state}`;
}
}
if (terminal && deployEvent.service_id && deployEvent.service_id === this.deployTrackId) {
this.deployTrackId = null;
const nome = this.serviceNameById(deployEvent.service_id);
if (deployEvent.state === "Live") {
this.toastOk(`${nome}: deploy concluído`);
} else {
this.toastError(`${nome}: ${motivo || "deploy " + deployEvent.state}`);
}
}
}
},
applySnapshot(snapshot) {
this.snap = snapshot;
const daemonStatus = snapshot.status;
if (daemonStatus) {
this.daemonVersion = daemonStatus.version || "";
this.daemonUptime = formatUptime(daemonStatus.uptime_secs);
this.servicesLabel = `${daemonStatus.services_running || 0}/${daemonStatus.services_total || 0}`;
}
const byId = {};
for (const job of snapshot.jobs || []) byId[job.job.id] = job.job;
this.jobsById = byId;
this.dataLoading = false;
},
searchChanged(searchText) {
this.search = searchText || "";
},
async stopAll() {
if (!confirm("Parar todos os serviços? Todos os serviços em execução serão parados agora.")) {
return;
}
if (!this.api) return;
this.statusLine = "parando todos…";
const stopAllManagedResponse = await this.api.rpcChecked("StopAllManaged");
this.statusLine = stopAllManagedResponse.ok ? "todos os serviços parados" : "falha ao parar";
this.toastResult(stopAllManagedResponse, "todos os serviços parados");
},
async clearFinished() {
if (!this.snap) return;
const deploymentIds = (this.snap.deployments || [])
.map((deployment) => deployment.deployment)
.filter((deployment) => deployment.state === "Stopped" || deployment.state === "Failed")
.map((deployment) => deployment.id);
if (deploymentIds.length === 0) {
this.toastWarn("nada para limpar");
return;
}
if (
!confirm(
`Remove do histórico ${deploymentIds.length} deployment(s) em estado Stopped ou Failed, e seus build logs. Ação irreversível.`
)
) {
return;
}
this.deploymentsMessage = `removendo ${deploymentIds.length} deployment(s)…`;
let removed = 0,
failed = 0;
for (const deploymentId of deploymentIds) {
const deployDeleteResponse = await this.api.rpcChecked({ DeployDelete: { deployment_id: deploymentId } });
if (deployDeleteResponse.ok) removed++;
else failed++;
}
this.deploymentsMessage = "";
if (failed === 0) this.toastOk(`${removed} deployment(s) removido(s)`);
else this.toastError(`${removed} removido(s), ${failed} falharam`);
},
async refreshNow() {
if (!this.api) return;
const snapshotResponse = await this.api.rpc("Snapshot");
if (snapshotResponse.ok && snapshotResponse.value && typeof snapshotResponse.value.Snapshot === "string") {
try {
this.applySnapshot(JSON.parse(snapshotResponse.value.Snapshot));
} catch {
}
}
},
async createProject(name, description) {
if (!name || !name.trim()) return { ok: false, error: "nome obrigatório" };
const projectCreateResponse = await this.api.rpcChecked({
ProjectCreate: { name: name.trim(), description: description?.trim() || null },
});
if (projectCreateResponse.ok) {
this.toastOk("projeto criado");
await this.refreshNow();
}
return projectCreateResponse;
},
async updateProject(projectId, name, description) {
const projectUpdateResponse = await this.api.rpcChecked({
ProjectUpdate: { id: projectId, name: name.trim(), description: description?.trim() || null },
});
this.toastResult(projectUpdateResponse, "projeto atualizado");
if (projectUpdateResponse.ok) await this.refreshNow();
return projectUpdateResponse;
},
async deleteProject(projectId) {
if (!confirm("Remover este projeto? Só funciona se não houver serviços nele.")) {
return { ok: false, error: "cancelado" };
}
const projectDeleteResponse = await this.api.rpcChecked({ ProjectDelete: { id: projectId } });
this.toastResult(projectDeleteResponse, "projeto removido");
if (projectDeleteResponse.ok) {
await this.refreshNow();
this.nav("projects");
}
return projectDeleteResponse;
},
openProject(projectId) {
this.selectedProjectId = projectId;
this.nav("project");
},
async saveProjectEnv(envVars, envComments) {
const projectEnvSetResponse = await this.api.rpcChecked({
ProjectEnvSet: {
project_id: this.selectedProjectId,
env_vars: envVars,
env_comments: envComments,
},
});
this.toastResult(projectEnvSetResponse, "variáveis salvas");
if (projectEnvSetResponse.ok) await this.refreshNow();
return projectEnvSetResponse;
},
async addSecret(name, value) {
if (!name.trim()) return { ok: false, error: "nome obrigatório" };
const secretSetResponse = await this.api.rpcChecked({
SecretSet: { project_id: this.selectedProjectId, name: name.trim(), value },
});
this.toastResult(secretSetResponse, `secret ${name.trim()} salvo`);
if (secretSetResponse.ok) await this.refreshNow();
return secretSetResponse;
},
async deleteSecret(name) {
if (!confirm(`Remover o secret "${name}"? Serviços que o referenciam vão falhar no próximo deploy.`)) {
return;
}
const secretDeleteResponse = await this.api.rpcChecked({
SecretDelete: { project_id: this.selectedProjectId, name },
});
this.toastResult(secretDeleteResponse, `secret ${name} removido`);
if (secretDeleteResponse.ok) await this.refreshNow();
},
openNewService(step) {
window.dispatchEvent(new CustomEvent("newservice-reset", { detail: { step } }));
this.showNewService = true;
},
closeNewService() {
this.showNewService = false;
},
openExportWin(serviceId) {
this.exportWin = { serviceId };
},
closeExportWin() {
this.exportWin = null;
},
openProjectWin(project) {
this.projectWin = project ? { id: project.id, name: project.name, description: project.description || "" } : {};
},
closeProjectWin() {
this.projectWin = null;
},
async fetchWizardCatalog(search) {
const wizardCatalogResponse = await this.api.rpc({ WizardCatalog: { search: search || "" } });
if (!wizardCatalogResponse.ok || !wizardCatalogResponse.value?.WizardCatalog) return { dbs: [], brokers: [], templates: [] };
const catalog = wizardCatalogResponse.value.WizardCatalog;
try {
return {
dbs: JSON.parse(catalog.dbs),
brokers: JSON.parse(catalog.brokers),
templates: JSON.parse(catalog.templates),
};
} catch {
return { dbs: [], brokers: [], templates: [] };
}
},
async wizardCreate(createRequest) {
const wizardCreateResponse = await this.api.rpcChecked({ WizardCreate: createRequest });
if (wizardCreateResponse.ok) {
this.toastOk("serviço criado");
await this.refreshNow();
this.closeNewService();
const created = wizardCreateResponse.value?.Service;
if (created) this.openService(created.id);
else this.nav("project");
}
return wizardCreateResponse;
},
async createServiceDirect(name, source, port, domain) {
const spec = {
name: name.trim(),
project_id: this.selectedProjectId,
source,
port: Number(port) || 80,
host_port: null,
domain: domain?.trim() || null,
tls_enabled: false,
env_vars: [],
env_comments: [],
volumes: [],
healthcheck: {
kind: "None",
interval_secs: 5,
timeout_secs: 3,
retries: 10,
start_period_secs: 5,
},
replicas: 1,
resources: { cpu_shares: 0, mem_limit_bytes: 0 },
run_command: null,
run_args: [],
db_kind: null,
domains: [],
};
const serviceCreateResponse = await this.api.rpcChecked({ ServiceCreate: spec });
if (serviceCreateResponse.ok) {
this.toastOk("serviço criado");
await this.refreshNow();
this.closeNewService();
const created = serviceCreateResponse.value?.Service;
if (created) this.openService(created.id);
else this.nav("project");
}
return serviceCreateResponse;
},
async openService(serviceId) {
this.selectedServiceId = serviceId;
this.serviceTab = "general";
this.serviceMessage = "";
this.serviceDeployments = [];
this.serviceLoading = true;
this.nav("service");
await this.fetchServiceDetail(serviceId);
},
async fetchServiceDetail(serviceId) {
const serviceGetResponse = await this.api.rpc({ ServiceGet: { id: serviceId } });
if (!serviceGetResponse.ok || !serviceGetResponse.value?.Service) {
this.serviceMessage = serviceGetResponse.ok ? "serviço não encontrado" : serviceGetResponse.error;
this.serviceLoading = false;
return;
}
this.serviceDetail = serviceGetResponse.value.Service;
const deployHistoryResponse = await this.api.rpc({ DeployHistory: { service_id: serviceId, limit: 30 } });
this.serviceDeployments = (deployHistoryResponse.ok && deployHistoryResponse.value?.Deployments) || [];
this.serviceLoading = false;
},
async saveServiceSpec(spec, okMessage) {
const serviceId = this.selectedServiceId;
const serviceUpdateResponse = await this.api.rpcChecked({ ServiceUpdate: { id: serviceId, spec } });
if (serviceUpdateResponse.ok) {
this.toastOk(okMessage || "salvo");
await this.fetchServiceDetail(serviceId);
await this.refreshNow();
} else {
this.toastError("erro: " + serviceUpdateResponse.error);
}
return serviceUpdateResponse;
},
async deleteService(serviceId) {
if (!confirm("Remover este serviço? Para o container e apaga o histórico. Ação irreversível.")) {
return;
}
const serviceDeleteResponse = await this.api.rpcChecked({ ServiceDelete: { id: serviceId } });
if (serviceDeleteResponse.ok) {
await this.refreshNow();
this.nav("project");
this.toastOk("serviço removido");
} else {
this.toastError("erro ao remover: " + serviceDeleteResponse.error);
}
},
async stopService(serviceId) {
if (!confirm("Parar serviço? O tráfego para ele será interrompido até um novo deploy.")) {
return;
}
const serviceStopResponse = await this.api.rpcChecked({ ServiceStop: { service_id: serviceId } });
this.toastResult(serviceStopResponse, "serviço parado");
await this.refreshNow();
},
async stopAndDeleteService(serviceId) {
const deploymentJobs = (this.snap?.jobs || [])
.filter((job) => job.job.trigger_service_id === serviceId)
.map((job) => job.job.name)
.sort();
let message = "O serviço será parado e removido.";
if (deploymentJobs.length > 0) {
message += ` Os jobs de Schedules que dependem dele também serão removidos, com histórico: ${deploymentJobs.join(", ")}.`;
}
message += " Essa ação não pode ser desfeita.";
if (!confirm(message)) return;
const serviceStopResponse = await this.api.rpcChecked({ ServiceStop: { service_id: serviceId } });
if (!serviceStopResponse.ok) {
this.toastError("erro ao parar: " + serviceStopResponse.error);
return;
}
const serviceDeleteResponse = await this.api.rpcChecked({ ServiceDelete: { id: serviceId } });
if (serviceDeleteResponse.ok) this.toastOk("serviço parado e removido");
else this.toastError("erro ao remover: " + serviceDeleteResponse.error);
await this.refreshNow();
},
async deployStart() {
const serviceId = this.selectedServiceId;
this.serviceMessage = "iniciando deploy…";
const deployStartResponse = await this.api.rpcChecked({ DeployStart: { service_id: serviceId } });
this.serviceMessage = "";
if (deployStartResponse.ok) this.deployTrackId = serviceId;
this.toastResult(deployStartResponse, "deploy iniciado");
await this.fetchServiceDetail(serviceId);
await this.refreshNow();
},
async deployAbort(deploymentId) {
const deployAbortResponse = await this.api.rpcChecked({ DeployAbort: { deployment_id: deploymentId } });
this.toastResult(deployAbortResponse, "deploy cancelado");
await this.fetchServiceDetail(this.selectedServiceId);
},
async deployRollback() {
if (!confirm("Reverter para o deploy anterior?")) return;
const serviceId = this.selectedServiceId;
const deployRollbackResponse = await this.api.rpcChecked({ DeployRollback: { service_id: serviceId } });
if (deployRollbackResponse.ok) this.deployTrackId = serviceId;
this.toastResult(deployRollbackResponse, "rollback iniciado");
await this.fetchServiceDetail(serviceId);
},
async deleteDeployment(deploymentId) {
const deployDeleteResponse = await this.api.rpcChecked({ DeployDelete: { deployment_id: deploymentId } });
this.toastResult(deployDeleteResponse, "deployment removido");
if (deployDeleteResponse.ok) await this.fetchServiceDetail(this.selectedServiceId);
return deployDeleteResponse;
},
async queueCancel(deploymentId) {
const deployAbortResponse = await this.api.rpcChecked({ DeployAbort: { deployment_id: deploymentId } });
this.toastResult(deployAbortResponse, "deploy cancelado");
await this.refreshNow();
},
async queuePromote(deploymentId) {
const deployQueuePromoteResponse = await this.api.rpcChecked({ DeployQueuePromote: { deployment_id: deploymentId } });
this.toastResult(deployQueuePromoteResponse, "deploy movido para o topo da fila");
await this.refreshNow();
},
async queueReorder(order) {
const deployQueueReorderResponse = await this.api.rpcChecked({ DeployQueueReorder: { order } });
this.toastResult(deployQueueReorderResponse, "fila reordenada");
await this.refreshNow();
},
async queueTogglePause() {
const paused = !!this.snap?.engine?.paused;
const deployQueuePauseResponse = await this.api.rpcChecked({ DeployQueuePause: { paused: !paused } });
this.toastResult(deployQueuePauseResponse, paused ? "fila retomada" : "fila pausada");
await this.refreshNow();
},
toastPrune(result) {
if (!result.ok) return this.toastError("erro: " + result.error);
const pruneResult = result.value?.PruneResult;
return this.toastOk(
pruneResult
? `removidos: ${pruneResult.count} · ${formatBytes(pruneResult.reclaimed_bytes)} liberados`
: "limpeza concluída"
);
},
async dockerPruneContainers() {
const pruneContainersResponse = await this.api.rpcChecked("PruneContainers");
this.toastPrune(pruneContainersResponse);
await this.refreshNow();
},
async dockerPruneImages() {
const pruneImagesResponse = await this.api.rpcChecked({ PruneImages: { all: this.pruneAllImages } });
this.toastPrune(pruneImagesResponse);
await this.refreshNow();
},
async dockerPruneVolumes() {
const pruneVolumesResponse = await this.api.rpcChecked({ PruneVolumes: { all: this.pruneAllVolumes } });
this.toastPrune(pruneVolumesResponse);
await this.refreshNow();
},
async dockerPruneNetworks() {
const pruneNetworksResponse = await this.api.rpcChecked("PruneNetworks");
this.toastPrune(pruneNetworksResponse);
await this.refreshNow();
},
async dockerRemoveContainer(containerId) {
const removeContainerResponse = await this.api.rpcChecked({ RemoveContainer: { id: containerId } });
this.toastResult(removeContainerResponse, "removido");
if (removeContainerResponse.ok) await this.refreshNow();
},
async dockerRemoveImage(imageId) {
const removeImageResponse = await this.api.rpcChecked({ RemoveImage: { id: imageId } });
this.toastResult(removeImageResponse, "removido");
if (removeImageResponse.ok) await this.refreshNow();
},
async dockerRemoveVolume(name) {
const removeVolumeResponse = await this.api.rpcChecked({ RemoveVolume: { name } });
this.toastResult(removeVolumeResponse, "removido");
if (removeVolumeResponse.ok) await this.refreshNow();
},
async dockerRemoveNetwork(networkId) {
const removeNetworkResponse = await this.api.rpcChecked({ RemoveNetwork: { id: networkId } });
this.toastResult(removeNetworkResponse, "removido");
if (removeNetworkResponse.ok) await this.refreshNow();
},
async dockerSetTab(tab) {
this.dockerTab = tab;
if (tab === "registry") await this.registryRefreshTokens();
},
async registryOpenRepo(name) {
this.registrySelectedRepo = name;
this.registryTagsLoading = true;
const registryTagListResponse = await this.api.rpc({ RegistryTagList: { repo: name } });
this.registryTags = (registryTagListResponse.ok && registryTagListResponse.value?.RegistryTags) || [];
this.registryTagsLoading = false;
},
registryCloseRepo() {
this.registrySelectedRepo = "";
this.registryTags = [];
},
async registryRefreshTokens() {
const registryTokenListResponse = await this.api.rpc("RegistryTokenList");
this.registryTokens = (registryTokenListResponse.ok && registryTokenListResponse.value?.RegistryTokens) || [];
},
async registryRemoveTag(tag) {
const repo = this.registrySelectedRepo;
const registryTagDeleteResponse = await this.api.rpcChecked({ RegistryTagDelete: { repo, tag } });
this.toastResult(registryTagDeleteResponse, `tag ${tag} removida`);
if (registryTagDeleteResponse.ok) {
await this.registryOpenRepo(repo);
await this.refreshNow();
}
},
async registryRemoveRepo(name) {
const registryRepoDeleteResponse = await this.api.rpcChecked({ RegistryRepoDelete: { repo: name } });
this.toastResult(registryRepoDeleteResponse, `repositório ${name} removido`);
if (registryRepoDeleteResponse.ok) {
if (this.registrySelectedRepo === name) this.registryCloseRepo();
await this.refreshNow();
}
},
async registryGc() {
const registryGcResponse = await this.api.rpcChecked("RegistryGc");
if (registryGcResponse.ok) {
const gcResult = registryGcResponse.value?.RegistryGcResult;
this.toastOk(
gcResult
? `GC: ${gcResult.blobs_removed} arquivo(s) removido(s) · ${formatBytes(gcResult.bytes_freed)} liberados`
: "GC concluído"
);
await this.refreshNow();
} else {
this.toastError("erro: " + registryGcResponse.error);
}
},
async registryRemoveToken(name) {
const registryTokenRevokeResponse = await this.api.rpcChecked({ RegistryTokenRevoke: { name } });
this.toastResult(registryTokenRevokeResponse, `token ${name} revogado`);
if (registryTokenRevokeResponse.ok) await this.registryRefreshTokens();
},
async registryCreateToken(name, scope) {
const registryTokenCreateResponse = await this.api.rpcChecked({ RegistryTokenCreate: { name, scope } });
if (!registryTokenCreateResponse.ok) return { ok: false, error: registryTokenCreateResponse.error };
await this.registryRefreshTokens();
return { ok: true, secret: registryTokenCreateResponse.value.RegistryTokenCreated.secret };
},
async jobRunNow(jobId) {
if (this.jobsInflight[jobId]) return;
this.jobsInflight[jobId] = true;
try {
const jobRunNowResponse = await this.api.rpcChecked({ JobRunNow: { id: jobId } });
this.toastResult(jobRunNowResponse, "job disparado");
await this.refreshNow();
} finally {
delete this.jobsInflight[jobId];
}
},
async jobRunCancel(jobRunId) {
if (!jobRunId) return;
const jobRunCancelResponse = await this.api.rpcChecked({ JobRunCancel: { job_run_id: jobRunId } });
this.toastResult(jobRunCancelResponse, "cancelamento solicitado");
await this.refreshNow();
},
async jobToggle(jobId) {
const job = this.jobsById[jobId];
if (!job) {
this.toastWarn("job não encontrado no snapshot atual");
return;
}
const jobUpdateResponse = await this.api.rpcChecked({
JobUpdate: {
id: job.id,
name: job.name,
compose: job.compose,
git_source: job.git_source ?? null,
main_service: job.main_service,
env_vars: job.env_vars ?? [],
env_comments: job.env_comments ?? [],
enabled: !job.enabled,
recurrence: job.recurrence,
},
});
this.toastResult(jobUpdateResponse, job.enabled ? "job desativado" : "job ativado");
await this.refreshNow();
},
async jobDelete(jobId) {
if (!confirm("Remover este job? Ação irreversível.")) return;
const jobDeleteResponse = await this.api.rpcChecked({ JobDelete: { id: jobId } });
this.toastResult(jobDeleteResponse, "job removido");
await this.refreshNow();
},
async jobWarm(jobId) {
const jobWarmResponse = await this.api.rpcChecked({ JobWarm: { job_id: jobId } });
if (!this.toastResult(jobWarmResponse)) return;
if (jobWarmResponse.value.JobWarmRequested.already_warm) this.toastOk("job já está aquecido");
else this.toastOk("aquecimento iniciado");
await this.refreshNow();
},
async jobRebuild(jobId) {
if (!confirm("Reconstruir este job do zero (sem cache, com as imagens base rebaixadas)? Pode levar vários minutos e ocupa a fila de jobs enquanto roda.")) return;
const jobRebuildResponse = await this.api.rpcChecked({ JobWarm: { job_id: jobId, force: true } });
if (!this.toastResult(jobRebuildResponse)) return;
this.toastOk("reconstrução iniciada");
await this.refreshNow();
},
openJobActions(jobId) {
this.jobActionsJobId = jobId;
},
closeJobActions() {
this.jobActionsJobId = "";
},
get jobActionsRow() {
if (!this.jobActionsJobId) return null;
return (
jobSummaryRows(this.snap?.jobs, "", this.jobsInflight).find((row) => row.id === this.jobActionsJobId) || null
);
},
async jobCreate(payload) {
const jobCreateResponse = await this.api.rpcChecked({ JobCreate: payload });
if (jobCreateResponse.ok) await this.refreshNow();
return jobCreateResponse;
},
get newJobProjects() {
return this.snap?.projects || [];
},
get newJobServicesFiltered() {
return (this.snap?.services || []).filter(
(service) => service.service.spec.project_id === this.newJobProjectId
);
},
openNewJob() {
this.showNewJob = true;
this.newJobStep = "pick_project";
this.newJobEditId = null;
this.newJobEnabled = true;
this.newJobProjectId = "";
this.newJobProjectName = "";
this.newJobServiceId = "";
this.newJobServiceName = "";
this.newJobName = "";
this.newJobCompose = "";
this.newJobSourceTab = "compose";
this.newJobGitProviderId = "";
this.newJobGitProviders = [];
this.newJobGitRepos = [];
this.newJobGitRepoFullName = "";
this.newJobGitBranches = [];
this.newJobGitBranch = "";
this.newJobComposePath = "docker-compose.yml";
this.newJobGitMessage = "";
this.newJobMainService = "";
this.newJobEnvText = "";
this.newJobKind = "manual";
this.newJobHours = "6";
this.newJobTime = "03:00";
this.newJobWeekday = "0";
this.newJobError = "";
},
closeNewJob() {
this.showNewJob = false;
},
newJobPickProject(projectId, name) {
this.newJobProjectId = projectId;
this.newJobProjectName = name;
this.newJobStep = "pick_service";
},
newJobPickService(serviceId, name) {
this.newJobServiceId = serviceId;
this.newJobServiceName = name;
this.newJobStep = "form";
},
newJobPickNoService() {
this.newJobServiceId = "";
this.newJobServiceName = "nenhum (autônomo)";
this.newJobStep = "form";
},
newJobBack() {
if (this.newJobEditId) {
this.closeNewJob();
return;
}
if (this.newJobStep === "form") this.newJobStep = "pick_service";
else if (this.newJobStep === "pick_service") this.newJobStep = "pick_project";
},
async newJobSetSourceTab(kind) {
this.newJobSourceTab = kind;
if (kind === "git" && this.newJobGitProviders.length === 0) {
this.newJobGitMessage = "carregando contas…";
const gitProviderListResponse = await this.api.rpc("GitProviderList");
if (gitProviderListResponse.ok && gitProviderListResponse.value?.GitProviders) {
this.newJobGitProviders = gitProviderListResponse.value.GitProviders;
this.newJobGitMessage = "";
} else {
this.newJobGitMessage = "erro ao listar contas conectadas";
}
}
},
async newJobGitProviderPick(providerId) {
this.newJobGitProviderId = providerId || "";
this.newJobGitRepoFullName = "";
this.newJobGitRepos = [];
this.newJobGitBranches = [];
if (!this.newJobGitProviderId) return;
this.newJobGitMessage = "carregando repositórios…";
const gitRepoListResponse = await this.api.rpc({ GitRepoList: { provider_id: this.newJobGitProviderId } });
if (gitRepoListResponse.ok && gitRepoListResponse.value?.GitRepos) {
this.newJobGitRepos = gitRepoListResponse.value.GitRepos;
this.newJobGitMessage = `${gitRepoListResponse.value.GitRepos.length} repositório(s)`;
} else {
this.newJobGitMessage = "erro ao listar repositórios";
}
},
async newJobGitRepoPick(fullName) {
if (!fullName) return;
this.newJobGitRepoFullName = fullName;
const repo = this.newJobGitRepos.find((newJobGitRepo) => newJobGitRepo.full_name === fullName);
if (repo?.default_branch) this.newJobGitBranch = repo.default_branch;
this.newJobGitBranches = [];
if (!this.newJobGitProviderId) return;
this.newJobGitMessage = "carregando branches…";
const gitBranchListResponse = await this.api.rpc({
GitBranchList: { provider_id: this.newJobGitProviderId, repo_full_name: fullName },
});
if (gitBranchListResponse.ok && gitBranchListResponse.value?.GitBranches) {
this.newJobGitBranches = gitBranchListResponse.value.GitBranches;
this.newJobGitMessage = "";
} else {
this.newJobGitMessage = "erro ao listar branches";
}
},
buildNewJobRecurrence() {
if (this.newJobKind === "interval") {
return { IntervalHours: Math.max(1, Number(this.newJobHours) || 1) };
}
const [hora, minuto] = hourMinuteSplit(this.newJobTime);
if (this.newJobKind === "daily") {
return { Daily: { hour: hora, minute: minuto } };
}
if (this.newJobKind === "weekly") {
return {
Weekly: {
weekday: Number(this.newJobWeekday) || 0,
hour: hora,
minute: minuto,
},
};
}
return null;
},
async newJobCreate() {
if (!this.newJobName.trim() || !this.newJobMainService.trim()) {
this.newJobError = "nome e main service são obrigatórios";
return;
}
let compose = "";
let gitSource = null;
if (this.newJobSourceTab === "git") {
const branch = this.newJobGitBranch.trim();
if (!this.newJobGitRepoFullName || !branch) {
this.newJobError = "selecione repositório e branch";
return;
}
const composePath = this.newJobComposePath.trim() || "docker-compose.yml";
const repo = this.newJobGitRepos.find((newJobGitRepo) => newJobGitRepo.full_name === this.newJobGitRepoFullName);
if (!repo?.clone_url) {
this.newJobError = "não foi possível resolver a URL do repositório — selecione de novo";
return;
}
gitSource = {
provider_id: this.newJobGitProviderId || null,
url: repo.clone_url,
branch,
username: null,
credentials: null,
compose_path: composePath,
};
} else {
compose = this.newJobCompose;
if (!compose.trim()) {
this.newJobError = "cole o docker-compose.yml do job";
return;
}
}
this.newJobError = "";
this.newJobSubmitting = true;
const { vars: envVars, comments: envComments } = parseDotenv(this.newJobEnvText);
let saveResponse;
if (this.newJobEditId) {
saveResponse = await this.api.rpcChecked({
JobUpdate: {
id: this.newJobEditId,
name: this.newJobName.trim(),
compose,
git_source: gitSource,
main_service: this.newJobMainService.trim(),
env_vars: envVars,
env_comments: envComments,
enabled: this.newJobEnabled,
recurrence: this.buildNewJobRecurrence(),
},
});
if (saveResponse.ok) await this.refreshNow();
} else {
saveResponse = await this.jobCreate({
project_id: this.newJobProjectId,
trigger_service_id: this.newJobServiceId || "",
name: this.newJobName.trim(),
compose,
git_source: gitSource,
main_service: this.newJobMainService.trim(),
env_vars: envVars,
env_comments: envComments,
recurrence: this.buildNewJobRecurrence(),
});
}
this.newJobSubmitting = false;
if (saveResponse.ok) {
this.toastOk(this.newJobEditId ? "job atualizado" : "job criado");
this.closeNewJob();
} else {
this.newJobError = saveResponse.error;
}
},
async openEditJob(jobId) {
const job = this.jobsById[jobId];
if (!job) {
this.toastWarn("job não encontrado no snapshot atual");
return;
}
this.showNewJob = true;
this.newJobEditId = jobId;
this.newJobStep = "form";
this.newJobError = "";
this.newJobName = job.name || "";
this.newJobMainService = job.main_service || "";
this.newJobEnabled = !!job.enabled;
this.newJobEnvText = dotenvFromVars(job.env_vars, job.env_comments);
const recurrence = job.recurrence;
if (recurrence?.IntervalHours != null) {
this.newJobKind = "interval";
this.newJobHours = String(recurrence.IntervalHours);
} else if (recurrence?.Daily) {
this.newJobKind = "daily";
this.newJobTime = hourMinuteJoin(recurrence.Daily.hour, recurrence.Daily.minute);
} else if (recurrence?.Weekly) {
this.newJobKind = "weekly";
this.newJobWeekday = String(recurrence.Weekly.weekday);
this.newJobTime = hourMinuteJoin(recurrence.Weekly.hour, recurrence.Weekly.minute);
} else {
this.newJobKind = "manual";
}
if (job.git_source) {
this.newJobSourceTab = "git";
this.newJobCompose = "";
this.newJobGitBranch = job.git_source.branch || "";
this.newJobComposePath = job.git_source.compose_path || "docker-compose.yml";
this.newJobGitProviderId = job.git_source.provider_id || "";
if (this.newJobGitProviderId) {
this.newJobGitMessage = "carregando repositórios…";
const gitProviderListResponse = await this.api.rpc("GitProviderList");
if (gitProviderListResponse.ok && gitProviderListResponse.value?.GitProviders) this.newJobGitProviders = gitProviderListResponse.value.GitProviders;
const gitRepoListResponse = await this.api.rpc({ GitRepoList: { provider_id: this.newJobGitProviderId } });
if (gitRepoListResponse.ok && gitRepoListResponse.value?.GitRepos) {
this.newJobGitRepos = gitRepoListResponse.value.GitRepos;
const match = this.newJobGitRepos.find((repo) => repo.clone_url === job.git_source.url);
this.newJobGitRepoFullName = match?.full_name || "";
if (this.newJobGitRepoFullName) {
this.newJobGitMessage = "carregando branches…";
const gitBranchListResponse = await this.api.rpc({
GitBranchList: {
provider_id: this.newJobGitProviderId,
repo_full_name: this.newJobGitRepoFullName,
},
});
if (gitBranchListResponse.ok && gitBranchListResponse.value?.GitBranches) this.newJobGitBranches = gitBranchListResponse.value.GitBranches;
}
}
this.newJobGitMessage = "";
}
} else {
this.newJobSourceTab = "compose";
this.newJobCompose = job.compose || "";
}
},
openJobLogs(jobRunId) {
this.jobLogsFor = jobRunId;
this.startJobLogs(jobRunId);
},
closeJobLogs() {
this.jobLogsFor = null;
this.stopJobLogs();
},
async startJobLogs(jobRunId) {
this.stopJobLogs();
const seed = await this.api.rpc({ GetJobLogs: { job_run_id: jobRunId } });
this.jobLogLines = ((seed.ok && seed.value?.JobLogs) || []).map(this.cleanLogEntry);
this.jobLogStream = openStream(
this.api.baseUrl,
this.api.token,
`/api/jobs/runs/${jobRunId}/logs`,
{
onEvent: (_kind, data) => {
if (data?.kind === "bus_batch" && Array.isArray(data.events)) {
for (const event of data.events) {
const line = event?.JobLogLine;
if (line) this.jobLogLines.push(this.cleanLogEntry(line));
}
const MAX = 2000;
if (this.jobLogLines.length > MAX) {
this.jobLogLines.splice(0, this.jobLogLines.length - MAX);
}
}
},
}
);
},
stopJobLogs() {
this.jobLogStream?.close();
this.jobLogStream = null;
},
async loadSettings() {
const getDaemonSettingsResponse = await this.api.rpc("GetDaemonSettings");
if (getDaemonSettingsResponse.ok && getDaemonSettingsResponse.value?.DaemonSettings) {
const settings = getDaemonSettingsResponse.value.DaemonSettings;
this.serverSettingsPublicBase = settings.public_base_url || "";
this.serverSettingsEmail = settings.acme_email || "";
this.serverSettingsRegistryDomain = settings.registry_domain || "";
}
this.serverAccessKey = this.api.token ? encodeAccessKey(this.api.baseUrl, this.api.token) : "";
this.serverMcpCommand =
"claude mcp add --transport http --scope user rustploy " + this.api.baseUrl + "/mcp" +
(this.api.token ? ' --header "Authorization: Bearer ' + this.api.token + '"' : "");
await this.gitProviderRefresh();
await this.dockerCleanupLoad();
},
async settingsSave() {
const email = this.serverSettingsEmail.trim();
const registryDomain = this.serverSettingsRegistryDomain.trim();
this.settingsMessage = "salvando…";
const setDaemonSettingsResponse = await this.api.rpcChecked({
SetDaemonSettings: {
acme_email: email || null,
registry_domain: registryDomain || null,
},
});
this.settingsMessage = "";
this.toastResult(setDaemonSettingsResponse, "configurações salvas");
},
async gitProviderRefresh() {
const gitProviderListResponse = await this.api.rpc("GitProviderList");
this.gitProviders = (gitProviderListResponse.ok && gitProviderListResponse.value?.GitProviders) || [];
},
async gitProviderConnect() {
this.gitProviderOauthUrl = "";
const isGithub = this.gitProviderKind === "github";
const kindWire = isGithub ? "Github" : "Gitea";
const label = isGithub ? "GitHub" : "Gitea";
let base = this.gitProviderBaseUrl.trim();
if (!base) {
if (isGithub) base = "https://github.com";
else {
this.toastWarn("informe a Base URL do Gitea");
return;
}
}
const name = this.gitProviderName.trim() || label;
const isOauth = this.gitProviderMode !== "pat";
let providerCommand;
if (isOauth) {
const clientId = this.gitProviderClientId.trim();
const csec = this.gitProviderClientSecret || "";
if (!clientId || !csec.trim()) {
this.toastWarn("Client ID e Client Secret são obrigatórios");
return;
}
providerCommand = {
GitProviderCreate: {
kind: kindWire,
name,
base_url: base,
auth_mode: "OAuth",
oauth_client_id: clientId,
oauth_client_secret: csec,
pat: null,
},
};
} else {
const personalAccessToken = this.gitProviderPersonalAccessToken || "";
if (!personalAccessToken.trim()) {
this.toastWarn("informe o Personal Access Token");
return;
}
providerCommand = {
GitProviderCreate: {
kind: kindWire,
name,
base_url: base,
auth_mode: "Pat",
oauth_client_id: null,
oauth_client_secret: null,
pat: personalAccessToken,
},
};
}
this.gitProviderMessage = "conectando…";
const rpcResponse = await this.api.rpc(providerCommand);
if (!rpcResponse.ok || !rpcResponse.value?.GitProviderInfo) {
this.gitProviderMessage = "";
this.toastError("erro: " + (rpcResponse.ok ? "resposta inesperada" : rpcResponse.error));
return;
}
const providerId = rpcResponse.value.GitProviderInfo.id;
if (isOauth) {
const gitOAuthStartResponse = await this.api.rpc({ GitOAuthStart: { provider_id: providerId } });
if (gitOAuthStartResponse.ok && gitOAuthStartResponse.value?.OAuthUrl) {
this.gitProviderOauthUrl = gitOAuthStartResponse.value.OAuthUrl;
this.gitProviderMessage = "autorize a janela aberta e depois clique em Atualizar";
window.open(gitOAuthStartResponse.value.OAuthUrl, "_blank");
} else {
this.gitProviderMessage = "provider criado; inicie o OAuth manualmente";
}
} else {
this.gitProviderMessage = "";
this.toastOk(`conta ${label} conectada`);
}
this.gitProviderName = "";
this.gitProviderBaseUrl = "";
this.gitProviderClientId = "";
this.gitProviderClientSecret = "";
this.gitProviderPersonalAccessToken = "";
await this.gitProviderRefresh();
},
async gitProviderDelete(providerId) {
const gitProviderDeleteResponse = await this.api.rpcChecked({ GitProviderDelete: { id: providerId } });
this.gitProviderMessage = "";
this.toastResult(gitProviderDeleteResponse, "provider removido");
await this.gitProviderRefresh();
},
async manifestExport() {
this.manifestExportMessage = "exportando…";
const manifestExportAllResponse = await this.api.rpcChecked("ManifestExportAll");
if (!manifestExportAllResponse.ok || !manifestExportAllResponse.value?.ManifestBundle) {
const reason = manifestExportAllResponse.ok ? "resposta inesperada" : manifestExportAllResponse.error;
this.manifestExportMessage = "erro: " + reason;
this.toastError("erro: " + reason);
return;
}
const bundle = manifestExportAllResponse.value.ManifestBundle;
this.manifestYaml = bundle.yaml || "";
this.manifestDotenv = bundle.dotenv || "";
this.manifestHasExport = true;
const zipName = "rustploy-infra.zip";
const zipUrl = URL.createObjectURL(buildManifestZip(this.manifestYaml, this.manifestDotenv));
const link = document.createElement("a");
link.href = zipUrl;
link.download = zipName;
document.body.appendChild(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(zipUrl), 10000);
this.manifestExportMessage = "baixado como " + zipName;
this.toastOk("Infra as Code exportado");
},
async manifestPickZip(event) {
const input = event.target;
const file = input.files?.[0];
input.value = ""; if (!file) return;
const zipManifest = await readManifestZip(file);
if (!zipManifest.ok) {
this.manifestImportMessage = "zip inválido: " + zipManifest.error.replace(/^zip inválido: /, "");
this.toastError(zipManifest.error.replace(/^zip inválido: /, ""));
return;
}
this.manifestImportYaml = zipManifest.yaml;
this.manifestImportDotenv = zipManifest.toml;
this.manifestZipName = file.name;
this.manifestImportMessage = "zip lido: " + file.name + " — confira as opções e clique em Importar";
},
async manifestImport() {
this.manifestHasMissing = false;
this.manifestHasReport = false;
this.manifestMissingVars = "";
this.manifestReportLines = [];
const yaml = this.manifestImportYaml || "";
if (!yaml.trim()) {
this.toastWarn("escolha o .zip do manifesto (ou cole o YAML)");
return;
}
const dotenv = this.manifestImportDotenv || "";
const prune = this.manifestPrune;
const deploy = this.manifestDeploy;
const origin = this.manifestZipName ? "Do zip " + this.manifestZipName + ": " : "";
const confirmed = confirm(
"Aplicar este manifesto?\n\n" +
origin + yaml.length + " B de YAML + " + dotenv.length + " B de TOML.\n\n" +
"• Remover serviços ausentes (prune): " + (prune ? "SIM" : "não") + "\n" +
"• Disparar deploy dos alterados: " + (deploy ? "SIM" : "não")
);
if (!confirmed) {
this.manifestImportMessage = "cancelado";
return;
}
this.manifestImportMessage = "importando…";
const manifestImportResponse = await this.api.rpc({
ManifestImport: { yaml, dotenv, prune, deploy },
});
if (!manifestImportResponse.ok) {
this.manifestImportMessage = "erro: " + manifestImportResponse.error;
this.toastError(manifestImportResponse.error);
return;
}
const applyResult = manifestImportResponse.value;
if (applyResult?.MissingEnvVars) {
this.manifestHasMissing = true;
this.manifestMissingVars = applyResult.MissingEnvVars.join(", ");
this.manifestImportMessage = "faltam variáveis — nada foi aplicado";
this.toastError("Faltam variáveis no TOML: " + this.manifestMissingVars);
return;
}
if (applyResult?.Err) {
this.manifestImportMessage = `erro: ${applyResult.Err.code}: ${applyResult.Err.message}`;
this.toastError(applyResult.Err.message);
return;
}
if (!applyResult?.ManifestReport) {
this.manifestImportMessage = "resposta inesperada do daemon";
return;
}
const actions = applyResult.ManifestReport.actions || [];
const lines = actions.map((action) => `[${action.action}] ${action.kind} ${action.name}`);
const pruned = actions.filter((action) => action.action === "Deleted").map((action) => action.name);
if (pruned.length > 0) {
lines.push("removidos (prune): " + pruned.join(", "));
} else if (prune) {
lines.push("prune ligado — nenhum serviço ausente para remover");
}
const deployed = applyResult.ManifestReport.deployed || [];
if (deployed.length > 0) {
lines.push("deploy disparado: " + deployed.join(", "));
} else if (deploy) {
lines.push("deploy ligado — nada mudou para disparar");
}
this.manifestReportLines = lines;
this.manifestHasReport = true;
this.manifestImportMessage = "import concluído ✓";
this.toastOk("Infra as Code importado");
await this.refreshNow();
},
dockerCleanupApplyConfig(config) {
this.dockerCleanupEnabled = !!config.enabled;
this.dockerCleanupContainers = !!config.containers;
this.dockerCleanupImages = !!config.images;
this.dockerCleanupImagesAll = !!config.images_all;
this.dockerCleanupVolumes = !!config.volumes;
this.dockerCleanupVolumesAll = !!config.volumes_all;
this.dockerCleanupNetworks = !!config.networks;
this.dockerCleanupBuildCache = !!config.build_cache;
this.dockerCleanupBuildCacheOlderThanHours = String(config.build_cache_older_than_hours ?? 168);
this.dockerCleanupImagesOlderThanHours = String(config.images_older_than_hours ?? 168);
const recurrence = config.recurrence;
if (recurrence && recurrence.IntervalHours != null) {
this.dockerCleanupKind = "interval";
this.dockerCleanupHours = String(recurrence.IntervalHours);
} else if (recurrence && recurrence.Weekly) {
this.dockerCleanupKind = "weekly";
this.dockerCleanupTime = hourMinuteJoin(recurrence.Weekly.hour, recurrence.Weekly.minute);
this.dockerCleanupWeekday = String(recurrence.Weekly.weekday);
} else if (recurrence && recurrence.Daily) {
this.dockerCleanupKind = "daily";
this.dockerCleanupTime = hourMinuteJoin(recurrence.Daily.hour, recurrence.Daily.minute);
} else {
this.dockerCleanupKind = "daily";
}
this.dockerCleanupNextRunLabel = config.next_run_at ? dateDayMonthHourMinute(config.next_run_at) : "—";
this.dockerCleanupLastRunAtRaw = config.last_run_at ?? null;
},
async dockerCleanupLoad() {
const dockerCleanupConfigGetResponse = await this.api.rpc("DockerCleanupConfigGet");
if (dockerCleanupConfigGetResponse.ok && dockerCleanupConfigGetResponse.value?.DockerCleanupConfig) {
this.dockerCleanupApplyConfig(dockerCleanupConfigGetResponse.value.DockerCleanupConfig.config);
this.dockerCleanupLastRunText = dockerCleanupLastRunSummary(dockerCleanupConfigGetResponse.value.DockerCleanupConfig.last_run);
}
},
dockerCleanupBuildRecurrence() {
if (this.dockerCleanupKind === "interval") {
return { IntervalHours: Math.max(1, parseInt(this.dockerCleanupHours, 10) || 1) };
}
const [hora, minuto] = hourMinuteSplit(this.dockerCleanupTime);
if (this.dockerCleanupKind === "weekly") {
return {
Weekly: {
weekday: parseInt(this.dockerCleanupWeekday, 10) || 0,
hour: hora,
minute: minuto,
},
};
}
return {
Daily: {
hour: hora,
minute: minuto,
},
};
},
async dockerCleanupSave() {
this.dockerCleanupMessage = "salvando…";
const dockerCleanupConfigSetResponse = await this.api.rpcChecked({
DockerCleanupConfigSet: {
config: {
enabled: this.dockerCleanupEnabled,
recurrence: this.dockerCleanupBuildRecurrence(),
containers: this.dockerCleanupContainers,
images: this.dockerCleanupImages,
images_all: this.dockerCleanupImagesAll,
volumes: this.dockerCleanupVolumes,
volumes_all: this.dockerCleanupVolumesAll,
networks: this.dockerCleanupNetworks,
build_cache: this.dockerCleanupBuildCache,
build_cache_older_than_hours: retentionHours(this.dockerCleanupBuildCacheOlderThanHours),
images_older_than_hours: retentionHours(this.dockerCleanupImagesOlderThanHours),
last_run_at: this.dockerCleanupLastRunAtRaw,
},
},
});
if (dockerCleanupConfigSetResponse.ok && dockerCleanupConfigSetResponse.value?.DockerCleanupConfig) {
this.dockerCleanupApplyConfig(dockerCleanupConfigSetResponse.value.DockerCleanupConfig.config);
this.dockerCleanupLastRunText = dockerCleanupLastRunSummary(dockerCleanupConfigSetResponse.value.DockerCleanupConfig.last_run);
this.dockerCleanupMessage = "";
this.toastOk("configurações salvas");
} else {
this.dockerCleanupMessage = "";
this.toastError("erro: " + dockerCleanupConfigSetResponse.error);
}
},
async dockerCleanupRunNow() {
const anySelected =
this.dockerCleanupContainers || this.dockerCleanupImages || this.dockerCleanupVolumes || this.dockerCleanupNetworks || this.dockerCleanupBuildCache;
if (!anySelected) {
this.toastWarn("marque pelo menos um recurso");
return;
}
if (
!confirm(
"Roda a limpeza dos recursos marcados imediatamente, fora do horário agendado. Ação irreversível."
)
) {
return;
}
this.dockerCleanupRunning = true;
this.dockerCleanupMessage = "executando…";
const dockerCleanupRunNowResponse = await this.api.rpcChecked("DockerCleanupRunNow");
if (!dockerCleanupRunNowResponse.ok) {
this.dockerCleanupRunning = false;
this.dockerCleanupMessage = "";
this.toastError("erro: " + dockerCleanupRunNowResponse.error);
}
},
async serviceStop() {
const serviceId = this.selectedServiceId;
const serviceStopResponse = await this.api.rpcChecked({ ServiceStop: { service_id: serviceId } });
this.toastResult(serviceStopResponse, "serviço parado");
await this.fetchServiceDetail(serviceId);
await this.refreshNow();
},
async serviceReload() {
const serviceId = this.selectedServiceId;
const serviceReloadResponse = await this.api.rpcChecked({ ServiceReload: { service_id: serviceId } });
this.toastResult(serviceReloadResponse, "serviço recarregado");
await this.fetchServiceDetail(serviceId);
},
cleanLogEntry(logEntry) {
const LINE_MAX = 4000;
let line = stripAnsi(logEntry.line || "");
if (line.length > LINE_MAX) line = line.slice(0, LINE_MAX) + "…";
return { stream: logEntry.stream, line, timestamp: logEntry.timestamp };
},
timeHms,
async startServiceLogs() {
this.stopServiceLogs();
const serviceId = this.selectedServiceId;
const seed = await this.api.rpc({ LogsGet: { service_id: serviceId, tail: 500 } });
this.serviceLogLines = ((seed.ok && seed.value?.Logs) || []).map(this.cleanLogEntry);
if (this.serviceTab !== "logs" || this.selectedServiceId !== serviceId) return;
this.serviceLogStream = openStream(
this.api.baseUrl,
this.api.token,
`/api/services/${serviceId}/logs`,
{
onEvent: (_kind, data) => {
if (data?.kind === "bus_batch" && Array.isArray(data.events)) {
for (const event of data.events) {
const line = event?.LogLine;
if (line) this.serviceLogLines.push(this.cleanLogEntry(line));
}
const MAX = 2000;
if (this.serviceLogLines.length > MAX) {
this.serviceLogLines.splice(0, this.serviceLogLines.length - MAX);
}
}
},
}
);
},
stopServiceLogs() {
this.serviceLogStream?.close();
this.serviceLogStream = null;
},
setServiceTab(tab) {
this.serviceTab = tab;
if (tab === "logs") this.startServiceLogs();
else this.stopServiceLogs();
},
});
});
Alpine.start();
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("/sw.js").catch(() => {
});
});
}