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 Alpine from "https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/module.esm.js";
import { Api } from "./net/api.js";
import { openStream } from "./net/sse.js";
import { registerDirectives } from "./directives.js";
import {
fmtUptime,
fmtBytes,
stripAnsi,
oauthRedirectUri,
timeHms,
dateDmHms,
dateDmHm,
parseDotenv,
dotenvFromVars,
dockerCleanupLastRunSummary,
shortReason,
hmJoin,
hmSplit,
} from "./fmt.js";
window.Alpine = Alpine;
registerDirectives(Alpine);
const PREFS_KEY = "rustploy.prefs";
function loadPrefs() {
try {
return JSON.parse(localStorage.getItem(PREFS_KEY) || "{}");
} catch {
return {};
}
}
function savePrefs(p) {
localStorage.setItem(PREFS_KEY, JSON.stringify(p));
}
document.addEventListener("alpine:init", () => {
const prefs = loadPrefs();
Alpine.store("app", {
screen: "login",
view: "deployments",
connected: false,
statusLine: "pronto para conectar",
error: "",
token: prefs.rememberToken && prefs.token ? prefs.token : "",
rememberToken: !!prefs.rememberToken,
api: null,
stream: null,
search: "",
snap: null,
dataLoading: true,
daemonVersion: "",
daemonUptime: "…",
servicesLabel: "…",
deploymentsMsg: "",
metricsById: {}, sysCpu: "—",
sysMem: "—",
sysDisk: "—",
sysLoad: "—",
dockerTab: "containers",
onlyUsedImages: false,
onlyUsedVolumes: false,
onlyUsedNetworks: false,
pruneAllImages: false,
pruneAllVolumes: false,
registrySelectedRepo: "",
registryTags: [],
registryTagsLoading: false,
registryTokens: [],
jobsById: {}, jobLogLines: [],
jobLogStream: null,
jobsInflight: {},
showNewJob: false,
njobStep: "pick_project", njobEditId: null,
njobEnabled: true,
njobProjectId: "",
njobProjectName: "",
njobServiceId: "",
njobServiceName: "",
njobName: "",
njobCompose: "",
njobSourceTab: "compose",
njobGitProviderId: "",
njobGitProviders: [],
njobGitRepos: [],
njobGitRepoFullName: "",
njobGitBranches: [],
njobGitBranch: "",
njobComposePath: "docker-compose.yml",
njobGitMsg: "",
njobMainService: "",
njobEnvText: "",
njobKind: "manual", njobHours: "6",
njobTime: "03:00",
njobWeekday: "0",
njobErr: "",
njobSubmitting: false,
jobLogsFor: null,
settingsTab: "web",
ssPublicBase: "",
ssEmail: "",
ssRegistryDomain: "",
settingsMsg: "",
gitProviders: [],
gpKind: "gitea", gpMode: "oauth", gpName: "",
gpBaseUrl: "",
gpClientId: "",
gpClientSecret: "",
gpPat: "",
gpMsg: "",
gpOauthUrl: "",
iacYaml: "",
iacDotenv: "",
iacHasExport: false,
iacExportMsg: "",
iacImportYaml: "",
iacImportDotenv: "",
iacPrune: false,
iacDeploy: false,
iacHasMissing: false,
iacMissingVars: "",
iacHasReport: false,
iacReportLines: [],
iacImportMsg: "",
dcEnabled: false,
dcKind: "daily", dcHours: "6",
dcTime: "03:00",
dcWeekday: "0",
dcContainers: false,
dcImages: false,
dcImagesAll: false,
dcVolumes: false,
dcVolumesAll: false,
dcNetworks: false,
dcBuildCache: false,
dcNextRunLabel: "—",
dcLastRunAtRaw: null,
dcLastRunText: "ainda não rodou",
dcRunning: false,
dcMsg: "",
get gpRedirect() {
return oauthRedirectUri(this.ssPublicBase, this.gpKind);
},
view: "deployments",
selectedProjectId: null,
selectedServiceId: null,
deployTrackId: null,
serviceDetail: null, serviceDeployments: [], serviceTab: "general",
serviceMsg: "",
serviceLoading: false,
serviceLogLines: [],
serviceLogStream: null,
toasts: [],
toastSeq: 0,
toast(message, kind = "info", durationMs = 4000) {
if (!message) return null;
const id = ++this.toastSeq;
this.toasts.push({ id, kind, message: String(message) });
setTimeout(() => this.dismissToast(id), durationMs);
return id;
},
toastOk(message) {
return this.toast(message, "success");
},
toastErr(message) {
return this.toast(message, "error");
},
toastWarn(message) {
return this.toast(message, "warning");
},
toastResult(r, okMessage) {
if (r.ok) {
if (okMessage) this.toastOk(okMessage);
} else {
this.toastErr("erro: " + r.error);
}
return r.ok;
},
dismissToast(id) {
const i = this.toasts.findIndex((t) => t.id === id);
if (i !== -1) this.toasts.splice(i, 1);
},
serviceNameById(id) {
const e = (this.snap?.services || []).find((x) => x.service.id === id);
return e?.service?.spec?.name || "serviço";
},
nav(view) {
this.stopServiceLogs();
this.view = view;
},
persistPrefs() {
savePrefs({
rememberToken: this.rememberToken,
token: this.rememberToken ? this.token : undefined,
});
},
async connect() {
const base = window.location.origin;
this.persistPrefs();
this.statusLine = "conectando…";
this.error = "";
const client = new Api(base, this.token);
const r = await client.rpc("DaemonStatus");
if (!r.ok) {
this.error = r.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: (msg) => {
this.statusLine = "stream: " + msg;
},
onClose: () => {
this.stream = null;
if (this.connected) {
this.statusLine = "conexão encerrada";
this.connected = false;
this.screen = "login";
}
},
});
},
onStreamEvent(msg) {
if (!msg || typeof msg !== "object") return;
if (msg.kind === "snapshot") this.applySnapshot(msg);
else if (msg.kind === "bus") this.applyBusEvent(msg.event);
},
applyBusEvent(ev) {
if (ev === "DeployQueueChanged") {
this.refreshNow();
return;
}
if (!ev || typeof ev !== "object") return;
if (ev.DockerCleanupCompleted) {
const p = ev.DockerCleanupCompleted;
this.dcRunning = false;
this.dcLastRunText = dockerCleanupLastRunSummary({ at: p.at, results: p.results });
this.dcLastRunAtRaw = p.at;
} else if (ev.ContainerMetrics) {
const p = ev.ContainerMetrics;
if (p.service_id) this.metricsById[p.service_id] = p;
} else if (ev.SystemMetrics) {
const s = ev.SystemMetrics;
this.sysCpu = `${(s.cpu_percent || 0).toFixed(0)}%`;
this.sysMem = `${fmtBytes(s.mem_used_bytes)} / ${fmtBytes(s.mem_total_bytes)}`;
this.sysDisk = `${fmtBytes(s.disk_used_bytes)} / ${fmtBytes(s.disk_total_bytes)}`;
this.sysLoad = `${(s.load_avg_1 || 0).toFixed(2)} ${(s.load_avg_5 || 0).toFixed(2)} ${(s.load_avg_15 || 0).toFixed(2)}`;
} else if (ev.DeployStateChanged) {
const d = ev.DeployStateChanged;
const terminal =
d.state === "Live" || d.state === "Failed" || d.state === "Stopped";
const motivo = shortReason(d.message);
if (d.service_id && d.service_id === this.selectedServiceId) {
if (terminal) {
this.serviceMsg =
d.state === "Live"
? "deploy concluído"
: motivo
? `deploy falhou: ${motivo}`
: `deploy: ${d.state}`;
this.fetchServiceDetail(d.service_id);
} else {
this.serviceMsg = `deploy · ${d.state}`;
}
}
if (terminal && d.service_id && d.service_id === this.deployTrackId) {
this.deployTrackId = null;
const nome = this.serviceNameById(d.service_id);
if (d.state === "Live") {
this.toastOk(`${nome}: deploy concluído`);
} else {
this.toastErr(`${nome}: ${motivo || "deploy " + d.state}`);
}
}
}
},
applySnapshot(msg) {
this.snap = msg;
const st = msg.status;
if (st) {
this.daemonVersion = st.version || "";
this.daemonUptime = fmtUptime(st.uptime_secs);
this.servicesLabel = `${st.services_running || 0}/${st.services_total || 0}`;
}
const byId = {};
for (const s of msg.jobs || []) byId[s.job.id] = s.job;
this.jobsById = byId;
this.dataLoading = false;
},
searchChanged(v) {
this.search = v || "";
},
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 r = await this.api.rpcChecked("StopAllManaged");
this.statusLine = r.ok ? "todos os serviços parados" : "falha ao parar";
this.toastResult(r, "todos os serviços parados");
},
async clearFinished() {
if (!this.snap) return;
const ids = (this.snap.deployments || [])
.map((s) => s.deployment)
.filter((d) => d.state === "Stopped" || d.state === "Failed")
.map((d) => d.id);
if (ids.length === 0) {
this.toastWarn("nada para limpar");
return;
}
if (
!confirm(
`Remove do histórico ${ids.length} deployment(s) em estado Stopped ou Failed, e seus build logs. Ação irreversível.`
)
) {
return;
}
this.deploymentsMsg = `removendo ${ids.length} deployment(s)…`;
let removed = 0,
failed = 0;
for (const id of ids) {
const r = await this.api.rpcChecked({ DeployDelete: { deployment_id: id } });
if (r.ok) removed++;
else failed++;
}
this.deploymentsMsg = "";
if (failed === 0) this.toastOk(`${removed} deployment(s) removido(s)`);
else this.toastErr(`${removed} removido(s), ${failed} falharam`);
},
async refreshNow() {
if (!this.api) return;
const r = await this.api.rpc("Snapshot");
if (r.ok && r.value && typeof r.value.Snapshot === "string") {
try {
this.applySnapshot(JSON.parse(r.value.Snapshot));
} catch {
}
}
},
async createProject(name, description) {
if (!name || !name.trim()) return { ok: false, error: "nome obrigatório" };
const r = await this.api.rpcChecked({
ProjectCreate: { name: name.trim(), description: description?.trim() || null },
});
if (r.ok) {
this.toastOk("projeto criado");
await this.refreshNow();
}
return r;
},
async updateProject(id, name, description) {
const r = await this.api.rpcChecked({
ProjectUpdate: { id, name: name.trim(), description: description?.trim() || null },
});
this.toastResult(r, "projeto atualizado");
if (r.ok) await this.refreshNow();
return r;
},
async deleteProject(id) {
if (!confirm("Remover este projeto? Só funciona se não houver serviços nele.")) {
return { ok: false, error: "cancelado" };
}
const r = await this.api.rpcChecked({ ProjectDelete: { id } });
this.toastResult(r, "projeto removido");
if (r.ok) {
await this.refreshNow();
this.nav("projects");
}
return r;
},
openProject(id) {
this.selectedProjectId = id;
this.nav("project");
},
async saveProjectEnv(envVars, envComments) {
const r = await this.api.rpcChecked({
ProjectEnvSet: {
project_id: this.selectedProjectId,
env_vars: envVars,
env_comments: envComments,
},
});
this.toastResult(r, "variáveis salvas");
if (r.ok) await this.refreshNow();
return r;
},
async addSecret(name, value) {
if (!name.trim()) return { ok: false, error: "nome obrigatório" };
const r = await this.api.rpcChecked({
SecretSet: { project_id: this.selectedProjectId, name: name.trim(), value },
});
this.toastResult(r, `secret ${name.trim()} salvo`);
if (r.ok) await this.refreshNow();
return r;
},
async deleteSecret(name) {
if (!confirm(`Remover o secret "${name}"? Serviços que o referenciam vão falhar no próximo deploy.`)) {
return;
}
const r = await this.api.rpcChecked({
SecretDelete: { project_id: this.selectedProjectId, name },
});
this.toastResult(r, `secret ${name} removido`);
if (r.ok) await this.refreshNow();
},
openNewService() {
this.nav("new_service");
},
async fetchWizardCatalog(search) {
const r = await this.api.rpc({ WizardCatalog: { search: search || "" } });
if (!r.ok || !r.value?.WizardCatalog) return { dbs: [], brokers: [], templates: [] };
const c = r.value.WizardCatalog;
try {
return {
dbs: JSON.parse(c.dbs),
brokers: JSON.parse(c.brokers),
templates: JSON.parse(c.templates),
};
} catch {
return { dbs: [], brokers: [], templates: [] };
}
},
async wizardCreate(req) {
const r = await this.api.rpcChecked({ WizardCreate: req });
if (r.ok) {
this.toastOk("serviço criado");
await this.refreshNow();
const created = r.value?.Service;
if (created) this.openService(created.id);
else this.nav("project");
}
return r;
},
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 r = await this.api.rpcChecked({ ServiceCreate: spec });
if (r.ok) {
this.toastOk("serviço criado");
await this.refreshNow();
const created = r.value?.Service;
if (created) this.openService(created.id);
else this.nav("project");
}
return r;
},
async openService(id) {
this.selectedServiceId = id;
this.serviceTab = "general";
this.serviceMsg = "";
this.serviceDeployments = [];
this.serviceLoading = true;
this.nav("service");
await this.fetchServiceDetail(id);
},
async fetchServiceDetail(id) {
const r = await this.api.rpc({ ServiceGet: { id } });
if (!r.ok || !r.value?.Service) {
this.serviceMsg = r.ok ? "serviço não encontrado" : r.error;
this.serviceLoading = false;
return;
}
this.serviceDetail = r.value.Service;
const rh = await this.api.rpc({ DeployHistory: { service_id: id, limit: 30 } });
this.serviceDeployments = (rh.ok && rh.value?.Deployments) || [];
this.serviceLoading = false;
},
async saveServiceSpec(spec, okMsg) {
const id = this.selectedServiceId;
const r = await this.api.rpcChecked({ ServiceUpdate: { id, spec } });
if (r.ok) {
this.toastOk(okMsg || "salvo");
await this.fetchServiceDetail(id);
await this.refreshNow();
} else {
this.toastErr("erro: " + r.error);
}
return r;
},
async deleteService(id) {
if (!confirm("Remover este serviço? Para o container e apaga o histórico. Ação irreversível.")) {
return;
}
const r = await this.api.rpcChecked({ ServiceDelete: { id } });
if (r.ok) {
await this.refreshNow();
this.nav("project");
this.toastOk("serviço removido");
} else {
this.toastErr("erro ao remover: " + r.error);
}
},
async stopService(id) {
if (!confirm("Parar serviço? O tráfego para ele será interrompido até um novo deploy.")) {
return;
}
const r = await this.api.rpcChecked({ ServiceStop: { service_id: id } });
this.toastResult(r, "serviço parado");
await this.refreshNow();
},
async stopAndDeleteService(id) {
const depJobs = (this.snap?.jobs || [])
.filter((s) => s.job.trigger_service_id === id)
.map((s) => s.job.name)
.sort();
let message = "O serviço será parado e removido.";
if (depJobs.length > 0) {
message += ` Os jobs de Schedules que dependem dele também serão removidos, com histórico: ${depJobs.join(", ")}.`;
}
message += " Essa ação não pode ser desfeita.";
if (!confirm(message)) return;
const r1 = await this.api.rpcChecked({ ServiceStop: { service_id: id } });
if (!r1.ok) {
this.toastErr("erro ao parar: " + r1.error);
return;
}
const r2 = await this.api.rpcChecked({ ServiceDelete: { id } });
if (r2.ok) this.toastOk("serviço parado e removido");
else this.toastErr("erro ao remover: " + r2.error);
await this.refreshNow();
},
async deployStart() {
const id = this.selectedServiceId;
this.serviceMsg = "iniciando deploy…";
const r = await this.api.rpcChecked({ DeployStart: { service_id: id } });
this.serviceMsg = "";
if (r.ok) this.deployTrackId = id;
this.toastResult(r, "deploy iniciado");
await this.fetchServiceDetail(id);
await this.refreshNow();
},
async deployAbort(deploymentId) {
const r = await this.api.rpcChecked({ DeployAbort: { deployment_id: deploymentId } });
this.toastResult(r, "deploy cancelado");
await this.fetchServiceDetail(this.selectedServiceId);
},
async deployRollback() {
if (!confirm("Reverter para o deploy anterior?")) return;
const id = this.selectedServiceId;
const r = await this.api.rpcChecked({ DeployRollback: { service_id: id } });
if (r.ok) this.deployTrackId = id;
this.toastResult(r, "rollback iniciado");
await this.fetchServiceDetail(id);
},
async deleteDeployment(deploymentId) {
const r = await this.api.rpcChecked({ DeployDelete: { deployment_id: deploymentId } });
this.toastResult(r, "deployment removido");
if (r.ok) await this.fetchServiceDetail(this.selectedServiceId);
return r;
},
async queueCancel(deploymentId) {
const r = await this.api.rpcChecked({ DeployAbort: { deployment_id: deploymentId } });
this.toastResult(r, "deploy cancelado");
await this.refreshNow();
},
async queuePromote(deploymentId) {
const r = await this.api.rpcChecked({ DeployQueuePromote: { deployment_id: deploymentId } });
this.toastResult(r, "deploy movido para o topo da fila");
await this.refreshNow();
},
async queueTogglePause() {
const paused = !!this.snap?.engine?.paused;
const r = await this.api.rpcChecked({ DeployQueuePause: { paused: !paused } });
this.toastResult(r, paused ? "fila retomada" : "fila pausada");
await this.refreshNow();
},
toastPrune(r) {
if (!r.ok) return this.toastErr("erro: " + r.error);
const pr = r.value?.PruneResult;
return this.toastOk(
pr
? `removidos: ${pr.count} · ${fmtBytes(pr.reclaimed_bytes)} liberados`
: "limpeza concluída"
);
},
async dockerPruneContainers() {
const r = await this.api.rpcChecked("PruneContainers");
this.toastPrune(r);
await this.refreshNow();
},
async dockerPruneImages() {
const r = await this.api.rpcChecked({ PruneImages: { all: this.pruneAllImages } });
this.toastPrune(r);
await this.refreshNow();
},
async dockerPruneVolumes() {
const r = await this.api.rpcChecked({ PruneVolumes: { all: this.pruneAllVolumes } });
this.toastPrune(r);
await this.refreshNow();
},
async dockerPruneNetworks() {
const r = await this.api.rpcChecked("PruneNetworks");
this.toastPrune(r);
await this.refreshNow();
},
async dockerRemoveContainer(id) {
const r = await this.api.rpcChecked({ RemoveContainer: { id } });
this.toastResult(r, "removido");
if (r.ok) await this.refreshNow();
},
async dockerRemoveImage(id) {
const r = await this.api.rpcChecked({ RemoveImage: { id } });
this.toastResult(r, "removido");
if (r.ok) await this.refreshNow();
},
async dockerRemoveVolume(name) {
const r = await this.api.rpcChecked({ RemoveVolume: { name } });
this.toastResult(r, "removido");
if (r.ok) await this.refreshNow();
},
async dockerRemoveNetwork(id) {
const r = await this.api.rpcChecked({ RemoveNetwork: { id } });
this.toastResult(r, "removido");
if (r.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 r = await this.api.rpc({ RegistryTagList: { repo: name } });
this.registryTags = (r.ok && r.value?.RegistryTags) || [];
this.registryTagsLoading = false;
},
registryCloseRepo() {
this.registrySelectedRepo = "";
this.registryTags = [];
},
async registryRefreshTokens() {
const r = await this.api.rpc("RegistryTokenList");
this.registryTokens = (r.ok && r.value?.RegistryTokens) || [];
},
async registryRmTag(tag) {
const repo = this.registrySelectedRepo;
const r = await this.api.rpcChecked({ RegistryTagDelete: { repo, tag } });
this.toastResult(r, `tag ${tag} removida`);
if (r.ok) {
await this.registryOpenRepo(repo);
await this.refreshNow();
}
},
async registryRmRepo(name) {
const r = await this.api.rpcChecked({ RegistryRepoDelete: { repo: name } });
this.toastResult(r, `repositório ${name} removido`);
if (r.ok) {
if (this.registrySelectedRepo === name) this.registryCloseRepo();
await this.refreshNow();
}
},
async registryGc() {
const r = await this.api.rpcChecked("RegistryGc");
if (r.ok) {
const gc = r.value?.RegistryGcResult;
this.toastOk(
gc
? `GC: ${gc.blobs_removed} arquivo(s) removido(s) · ${fmtBytes(gc.bytes_freed)} liberados`
: "GC concluído"
);
await this.refreshNow();
} else {
this.toastErr("erro: " + r.error);
}
},
async registryRmToken(name) {
const r = await this.api.rpcChecked({ RegistryTokenRevoke: { name } });
this.toastResult(r, `token ${name} revogado`);
if (r.ok) await this.registryRefreshTokens();
},
async registryCreateToken(name, scope) {
const r = await this.api.rpcChecked({ RegistryTokenCreate: { name, scope } });
if (!r.ok) return { ok: false, error: r.error };
await this.registryRefreshTokens();
return { ok: true, secret: r.value.RegistryTokenCreated.secret };
},
async jobRunNow(id) {
if (this.jobsInflight[id]) return;
this.jobsInflight[id] = true;
try {
const r = await this.api.rpcChecked({ JobRunNow: { id } });
this.toastResult(r, "job disparado");
await this.refreshNow();
} finally {
delete this.jobsInflight[id];
}
},
async jobRunCancel(jobRunId) {
if (!jobRunId) return;
const r = await this.api.rpcChecked({ JobRunCancel: { job_run_id: jobRunId } });
this.toastResult(r, "cancelamento solicitado");
await this.refreshNow();
},
async jobToggle(id) {
const job = this.jobsById[id];
if (!job) {
this.toastWarn("job não encontrado no snapshot atual");
return;
}
const r = 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(r, job.enabled ? "job desativado" : "job ativado");
await this.refreshNow();
},
async jobDelete(id) {
if (!confirm("Remover este job? Ação irreversível.")) return;
const r = await this.api.rpcChecked({ JobDelete: { id } });
this.toastResult(r, "job removido");
await this.refreshNow();
},
async jobCreate(payload) {
const r = await this.api.rpcChecked({ JobCreate: payload });
if (r.ok) await this.refreshNow();
return r;
},
get njobProjects() {
return this.snap?.projects || [];
},
get njobServicesFiltered() {
return (this.snap?.services || []).filter(
(e) => e.service.spec.project_id === this.njobProjectId
);
},
openNewJob() {
this.showNewJob = true;
this.njobStep = "pick_project";
this.njobEditId = null;
this.njobEnabled = true;
this.njobProjectId = "";
this.njobProjectName = "";
this.njobServiceId = "";
this.njobServiceName = "";
this.njobName = "";
this.njobCompose = "";
this.njobSourceTab = "compose";
this.njobGitProviderId = "";
this.njobGitProviders = [];
this.njobGitRepos = [];
this.njobGitRepoFullName = "";
this.njobGitBranches = [];
this.njobGitBranch = "";
this.njobComposePath = "docker-compose.yml";
this.njobGitMsg = "";
this.njobMainService = "";
this.njobEnvText = "";
this.njobKind = "manual";
this.njobHours = "6";
this.njobTime = "03:00";
this.njobWeekday = "0";
this.njobErr = "";
},
closeNewJob() {
this.showNewJob = false;
},
njobPickProject(id, name) {
this.njobProjectId = id;
this.njobProjectName = name;
this.njobStep = "pick_service";
},
njobPickService(id, name) {
this.njobServiceId = id;
this.njobServiceName = name;
this.njobStep = "form";
},
njobPickNoService() {
this.njobServiceId = "";
this.njobServiceName = "nenhum (autônomo)";
this.njobStep = "form";
},
njobBack() {
if (this.njobEditId) {
this.closeNewJob();
return;
}
if (this.njobStep === "form") this.njobStep = "pick_service";
else if (this.njobStep === "pick_service") this.njobStep = "pick_project";
},
async njobSetSourceTab(kind) {
this.njobSourceTab = kind;
if (kind === "git" && this.njobGitProviders.length === 0) {
this.njobGitMsg = "carregando contas…";
const r = await this.api.rpc("GitProviderList");
if (r.ok && r.value?.GitProviders) {
this.njobGitProviders = r.value.GitProviders;
this.njobGitMsg = "";
} else {
this.njobGitMsg = "erro ao listar contas conectadas";
}
}
},
async njobGitProviderPick(id) {
this.njobGitProviderId = id || "";
this.njobGitRepoFullName = "";
this.njobGitRepos = [];
this.njobGitBranches = [];
if (!this.njobGitProviderId) return;
this.njobGitMsg = "carregando repositórios…";
const r = await this.api.rpc({ GitRepoList: { provider_id: this.njobGitProviderId } });
if (r.ok && r.value?.GitRepos) {
this.njobGitRepos = r.value.GitRepos;
this.njobGitMsg = `${r.value.GitRepos.length} repositório(s)`;
} else {
this.njobGitMsg = "erro ao listar repositórios";
}
},
async njobGitRepoPick(fullName) {
if (!fullName) return;
this.njobGitRepoFullName = fullName;
const repo = this.njobGitRepos.find((r) => r.full_name === fullName);
if (repo?.default_branch) this.njobGitBranch = repo.default_branch;
this.njobGitBranches = [];
if (!this.njobGitProviderId) return;
this.njobGitMsg = "carregando branches…";
const r = await this.api.rpc({
GitBranchList: { provider_id: this.njobGitProviderId, repo_full_name: fullName },
});
if (r.ok && r.value?.GitBranches) {
this.njobGitBranches = r.value.GitBranches;
this.njobGitMsg = "";
} else {
this.njobGitMsg = "erro ao listar branches";
}
},
buildNjobRecurrence() {
if (this.njobKind === "interval") {
return { IntervalHours: Math.max(1, Number(this.njobHours) || 1) };
}
const [hora, minuto] = hmSplit(this.njobTime);
if (this.njobKind === "daily") {
return { Daily: { hour: hora, minute: minuto } };
}
if (this.njobKind === "weekly") {
return {
Weekly: {
weekday: Number(this.njobWeekday) || 0,
hour: hora,
minute: minuto,
},
};
}
return null;
},
async njobCreate() {
if (!this.njobName.trim() || !this.njobMainService.trim()) {
this.njobErr = "nome e main service são obrigatórios";
return;
}
let compose = "";
let gitSource = null;
if (this.njobSourceTab === "git") {
const branch = this.njobGitBranch.trim();
if (!this.njobGitRepoFullName || !branch) {
this.njobErr = "selecione repositório e branch";
return;
}
const composePath = this.njobComposePath.trim() || "docker-compose.yml";
const repo = this.njobGitRepos.find((r) => r.full_name === this.njobGitRepoFullName);
if (!repo?.clone_url) {
this.njobErr = "não foi possível resolver a URL do repositório — selecione de novo";
return;
}
gitSource = {
provider_id: this.njobGitProviderId || null,
url: repo.clone_url,
branch,
username: null,
credentials: null,
compose_path: composePath,
};
} else {
compose = this.njobCompose;
if (!compose.trim()) {
this.njobErr = "cole o docker-compose.yml do job";
return;
}
}
this.njobErr = "";
this.njobSubmitting = true;
const { vars: envVars, comments: envComments } = parseDotenv(this.njobEnvText);
let r;
if (this.njobEditId) {
r = await this.api.rpcChecked({
JobUpdate: {
id: this.njobEditId,
name: this.njobName.trim(),
compose,
git_source: gitSource,
main_service: this.njobMainService.trim(),
env_vars: envVars,
env_comments: envComments,
enabled: this.njobEnabled,
recurrence: this.buildNjobRecurrence(),
},
});
if (r.ok) await this.refreshNow();
} else {
r = await this.jobCreate({
project_id: this.njobProjectId,
trigger_service_id: this.njobServiceId || "",
name: this.njobName.trim(),
compose,
git_source: gitSource,
main_service: this.njobMainService.trim(),
env_vars: envVars,
env_comments: envComments,
recurrence: this.buildNjobRecurrence(),
});
}
this.njobSubmitting = false;
if (r.ok) {
this.toastOk(this.njobEditId ? "job atualizado" : "job criado");
this.closeNewJob();
} else {
this.njobErr = r.error;
}
},
async openEditJob(id) {
const job = this.jobsById[id];
if (!job) {
this.toastWarn("job não encontrado no snapshot atual");
return;
}
this.showNewJob = true;
this.njobEditId = id;
this.njobStep = "form";
this.njobErr = "";
this.njobName = job.name || "";
this.njobMainService = job.main_service || "";
this.njobEnabled = !!job.enabled;
this.njobEnvText = dotenvFromVars(job.env_vars, job.env_comments);
const rec = job.recurrence;
if (rec?.IntervalHours != null) {
this.njobKind = "interval";
this.njobHours = String(rec.IntervalHours);
} else if (rec?.Daily) {
this.njobKind = "daily";
this.njobTime = hmJoin(rec.Daily.hour, rec.Daily.minute);
} else if (rec?.Weekly) {
this.njobKind = "weekly";
this.njobWeekday = String(rec.Weekly.weekday);
this.njobTime = hmJoin(rec.Weekly.hour, rec.Weekly.minute);
} else {
this.njobKind = "manual";
}
if (job.git_source) {
this.njobSourceTab = "git";
this.njobCompose = "";
this.njobGitBranch = job.git_source.branch || "";
this.njobComposePath = job.git_source.compose_path || "docker-compose.yml";
this.njobGitProviderId = job.git_source.provider_id || "";
if (this.njobGitProviderId) {
this.njobGitMsg = "carregando repositórios…";
const rp = await this.api.rpc("GitProviderList");
if (rp.ok && rp.value?.GitProviders) this.njobGitProviders = rp.value.GitProviders;
const rr = await this.api.rpc({ GitRepoList: { provider_id: this.njobGitProviderId } });
if (rr.ok && rr.value?.GitRepos) {
this.njobGitRepos = rr.value.GitRepos;
const match = this.njobGitRepos.find((repo) => repo.clone_url === job.git_source.url);
this.njobGitRepoFullName = match?.full_name || "";
if (this.njobGitRepoFullName) {
this.njobGitMsg = "carregando branches…";
const rb = await this.api.rpc({
GitBranchList: {
provider_id: this.njobGitProviderId,
repo_full_name: this.njobGitRepoFullName,
},
});
if (rb.ok && rb.value?.GitBranches) this.njobGitBranches = rb.value.GitBranches;
}
}
this.njobGitMsg = "";
}
} else {
this.njobSourceTab = "compose";
this.njobCompose = 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 ev of data.events) {
const line = ev?.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 r = await this.api.rpc("GetDaemonSettings");
if (r.ok && r.value?.DaemonSettings) {
const s = r.value.DaemonSettings;
this.ssPublicBase = s.public_base_url || "";
this.ssEmail = s.acme_email || "";
this.ssRegistryDomain = s.registry_domain || "";
}
await this.gpRefresh();
await this.dcLoad();
},
async settingsSave() {
const email = this.ssEmail.trim();
const registryDomain = this.ssRegistryDomain.trim();
this.settingsMsg = "salvando…";
const r = await this.api.rpcChecked({
SetDaemonSettings: {
acme_email: email || null,
registry_domain: registryDomain || null,
},
});
this.settingsMsg = "";
this.toastResult(r, "configurações salvas");
},
async gpRefresh() {
const r = await this.api.rpc("GitProviderList");
this.gitProviders = (r.ok && r.value?.GitProviders) || [];
},
async gpConnect() {
this.gpOauthUrl = "";
const isGithub = this.gpKind === "github";
const kindWire = isGithub ? "Github" : "Gitea";
const label = isGithub ? "GitHub" : "Gitea";
let base = this.gpBaseUrl.trim();
if (!base) {
if (isGithub) base = "https://github.com";
else {
this.toastWarn("informe a Base URL do Gitea");
return;
}
}
const name = this.gpName.trim() || label;
const isOauth = this.gpMode !== "pat";
let cmd;
if (isOauth) {
const cid = this.gpClientId.trim();
const csec = this.gpClientSecret || "";
if (!cid || !csec.trim()) {
this.toastWarn("Client ID e Client Secret são obrigatórios");
return;
}
cmd = {
GitProviderCreate: {
kind: kindWire,
name,
base_url: base,
auth_mode: "OAuth",
oauth_client_id: cid,
oauth_client_secret: csec,
pat: null,
},
};
} else {
const pat = this.gpPat || "";
if (!pat.trim()) {
this.toastWarn("informe o Personal Access Token");
return;
}
cmd = {
GitProviderCreate: {
kind: kindWire,
name,
base_url: base,
auth_mode: "Pat",
oauth_client_id: null,
oauth_client_secret: null,
pat,
},
};
}
this.gpMsg = "conectando…";
const r = await this.api.rpc(cmd);
if (!r.ok || !r.value?.GitProviderInfo) {
this.gpMsg = "";
this.toastErr("erro: " + (r.ok ? "resposta inesperada" : r.error));
return;
}
const pid = r.value.GitProviderInfo.id;
if (isOauth) {
const ro = await this.api.rpc({ GitOAuthStart: { provider_id: pid } });
if (ro.ok && ro.value?.OAuthUrl) {
this.gpOauthUrl = ro.value.OAuthUrl;
this.gpMsg = "autorize a janela aberta e depois clique em Atualizar";
window.open(ro.value.OAuthUrl, "_blank");
} else {
this.gpMsg = "provider criado; inicie o OAuth manualmente";
}
} else {
this.gpMsg = "";
this.toastOk(`conta ${label} conectada`);
}
this.gpName = "";
this.gpBaseUrl = "";
this.gpClientId = "";
this.gpClientSecret = "";
this.gpPat = "";
await this.gpRefresh();
},
async gpDelete(id) {
const r = await this.api.rpcChecked({ GitProviderDelete: { id } });
this.gpMsg = "";
this.toastResult(r, "provider removido");
await this.gpRefresh();
},
async iacExport() {
this.iacExportMsg = "exportando…";
const r = await this.api.rpcChecked("ManifestExportAll");
if (!r.ok || !r.value?.ManifestBundle) {
this.iacExportMsg = "";
this.toastErr("erro: " + (r.ok ? "resposta inesperada" : r.error));
return;
}
this.iacYaml = r.value.ManifestBundle.yaml;
this.iacDotenv = r.value.ManifestBundle.dotenv;
this.iacHasExport = true;
this.iacExportMsg = "";
this.toastOk("manifesto exportado");
},
async iacImport() {
this.iacHasMissing = false;
this.iacHasReport = false;
this.iacMissingVars = "";
this.iacReportLines = [];
const yaml = this.iacImportYaml || "";
if (!yaml.trim()) {
this.toastWarn("cole o YAML do manifesto");
return;
}
this.iacImportMsg = "importando…";
const r = await this.api.rpc({
ManifestImport: {
yaml,
dotenv: this.iacImportDotenv || "",
prune: this.iacPrune,
deploy: this.iacDeploy,
},
});
if (!r.ok) {
this.iacImportMsg = "";
this.toastErr("erro: " + r.error);
return;
}
const v = r.value;
if (v?.MissingEnvVars) {
this.iacHasMissing = true;
this.iacMissingVars = v.MissingEnvVars.join(", ");
this.iacImportMsg = "faltam variáveis — nada foi aplicado";
this.toastErr("faltam variáveis — nada foi aplicado");
return;
}
if (v?.Err) {
this.iacImportMsg = "";
this.toastErr(`erro: ${v.Err.code}: ${v.Err.message}`);
return;
}
if (!v?.ManifestReport) {
this.iacImportMsg = "";
this.toastErr("resposta inesperada do daemon");
return;
}
const lines = (v.ManifestReport.actions || []).map(
(a) => `[${a.action}] ${a.kind} ${a.name}`
);
if ((v.ManifestReport.deployed || []).length > 0) {
lines.push("deploy disparado: " + v.ManifestReport.deployed.join(", "));
}
this.iacReportLines = lines;
this.iacHasReport = true;
this.iacImportMsg = "";
this.toastOk("import concluído");
await this.refreshNow();
},
dcApplyConfig(cfg) {
this.dcEnabled = !!cfg.enabled;
this.dcContainers = !!cfg.containers;
this.dcImages = !!cfg.images;
this.dcImagesAll = !!cfg.images_all;
this.dcVolumes = !!cfg.volumes;
this.dcVolumesAll = !!cfg.volumes_all;
this.dcNetworks = !!cfg.networks;
this.dcBuildCache = !!cfg.build_cache;
const r = cfg.recurrence;
if (r && r.IntervalHours != null) {
this.dcKind = "interval";
this.dcHours = String(r.IntervalHours);
} else if (r && r.Weekly) {
this.dcKind = "weekly";
this.dcTime = hmJoin(r.Weekly.hour, r.Weekly.minute);
this.dcWeekday = String(r.Weekly.weekday);
} else if (r && r.Daily) {
this.dcKind = "daily";
this.dcTime = hmJoin(r.Daily.hour, r.Daily.minute);
} else {
this.dcKind = "daily";
}
this.dcNextRunLabel = cfg.next_run_at ? dateDmHm(cfg.next_run_at) : "—";
this.dcLastRunAtRaw = cfg.last_run_at ?? null;
},
async dcLoad() {
const r = await this.api.rpc("DockerCleanupConfigGet");
if (r.ok && r.value?.DockerCleanupConfig) {
this.dcApplyConfig(r.value.DockerCleanupConfig.config);
this.dcLastRunText = dockerCleanupLastRunSummary(r.value.DockerCleanupConfig.last_run);
}
},
dcBuildRecurrence() {
if (this.dcKind === "interval") {
return { IntervalHours: Math.max(1, parseInt(this.dcHours, 10) || 1) };
}
const [hora, minuto] = hmSplit(this.dcTime);
if (this.dcKind === "weekly") {
return {
Weekly: {
weekday: parseInt(this.dcWeekday, 10) || 0,
hour: hora,
minute: minuto,
},
};
}
return {
Daily: {
hour: hora,
minute: minuto,
},
};
},
async dcSave() {
this.dcMsg = "salvando…";
const r = await this.api.rpcChecked({
DockerCleanupConfigSet: {
config: {
enabled: this.dcEnabled,
recurrence: this.dcBuildRecurrence(),
containers: this.dcContainers,
images: this.dcImages,
images_all: this.dcImagesAll,
volumes: this.dcVolumes,
volumes_all: this.dcVolumesAll,
networks: this.dcNetworks,
build_cache: this.dcBuildCache,
last_run_at: this.dcLastRunAtRaw,
},
},
});
if (r.ok && r.value?.DockerCleanupConfig) {
this.dcApplyConfig(r.value.DockerCleanupConfig.config);
this.dcLastRunText = dockerCleanupLastRunSummary(r.value.DockerCleanupConfig.last_run);
this.dcMsg = "";
this.toastOk("configurações salvas");
} else {
this.dcMsg = "";
this.toastErr("erro: " + r.error);
}
},
async dcRunNow() {
const anySelected =
this.dcContainers || this.dcImages || this.dcVolumes || this.dcNetworks || this.dcBuildCache;
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.dcRunning = true;
this.dcMsg = "executando…";
const r = await this.api.rpcChecked("DockerCleanupRunNow");
if (!r.ok) {
this.dcRunning = false;
this.dcMsg = "";
this.toastErr("erro: " + r.error);
}
},
async serviceStop() {
const id = this.selectedServiceId;
const r = await this.api.rpcChecked({ ServiceStop: { service_id: id } });
this.toastResult(r, "serviço parado");
await this.fetchServiceDetail(id);
await this.refreshNow();
},
async serviceReload() {
const id = this.selectedServiceId;
const r = await this.api.rpcChecked({ ServiceReload: { service_id: id } });
this.toastResult(r, "serviço recarregado");
await this.fetchServiceDetail(id);
},
cleanLogEntry(e) {
const LINE_MAX = 4000;
let line = stripAnsi(e.line || "");
if (line.length > LINE_MAX) line = line.slice(0, LINE_MAX) + "…";
return { stream: e.stream, line, timestamp: e.timestamp };
},
timeHms,
async startServiceLogs() {
this.stopServiceLogs();
const id = this.selectedServiceId;
const seed = await this.api.rpc({ LogsGet: { service_id: id, tail: 500 } });
this.serviceLogLines = ((seed.ok && seed.value?.Logs) || []).map(this.cleanLogEntry);
if (this.serviceTab !== "logs" || this.selectedServiceId !== id) return;
this.serviceLogStream = openStream(
this.api.baseUrl,
this.api.token,
`/api/services/${id}/logs`,
{
onEvent: (_kind, data) => {
if (data?.kind === "bus_batch" && Array.isArray(data.events)) {
for (const ev of data.events) {
const line = ev?.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(() => {
});
});
}