diff --git a/dashboard/src/components/StateLED.vue b/dashboard/src/components/StateLED.vue index c2eae3172..62635277a 100644 --- a/dashboard/src/components/StateLED.vue +++ b/dashboard/src/components/StateLED.vue @@ -7,14 +7,14 @@ defineProps({ }, state: { validator(value) { - // The value must match one of these strings - return ['success', 'warning', 'danger', ''].includes(value); + return ['success', 'warning', 'danger', 'idle', ''].includes(value); } }, }); function color(state) { if (state === 'success') return '#27CE65'; + else if (state === 'idle') return '#BCD0C3'; else if (state === 'warning') return '#f0ad4e'; else if (state === 'danger') return '#d9534f'; else return '#7c7c7c'; diff --git a/dashboard/src/views/ServicesView.vue b/dashboard/src/views/ServicesView.vue index 5f48b478a..006b6f23a 100644 --- a/dashboard/src/views/ServicesView.vue +++ b/dashboard/src/views/ServicesView.vue @@ -101,8 +101,8 @@ async function refresh(id) { services[id].memoryPercent = result.memoryPercent || 0; services[id].defaultMemoryLimit = result.defaultMemoryLimit; - // we will poll until active - if (result.status !== 'active' && !result.config.recoveryMode) servicesTimers[id] = setTimeout(refresh.bind(null, id), 3000); + // we will poll until active (idle services are intentionally stopped, no need to poll) + if (result.status !== 'active' && result.status !== 'idle' && !result.config.recoveryMode) servicesTimers[id] = setTimeout(refresh.bind(null, id), 3000); } const refreshBusy = ref(false); @@ -196,8 +196,10 @@ async function onEditSubmit() { function state(service) { switch (service.status) { case 'active': return 'success'; + case 'idle': return 'idle'; case 'disabled': return ''; case 'stopped': return 'danger'; + case 'error': return 'danger'; case 'starting': return service.config.recoveryMode ? '' : 'warning'; default: return 'danger'; } @@ -206,8 +208,10 @@ function state(service) { function stateTooltip(service) { switch (service.status) { case 'active': return 'Active'; + case 'idle': return 'Idle (starts on demand)'; case 'disabled': return 'Disabled'; case 'stopped': return 'Stopped'; + case 'error': return 'Error'; case 'starting': return service.config.recoveryMode ? 'Recovery mode' : 'Starting'; default: return service.status; } diff --git a/src/metrics.js b/src/metrics.js index 37224a078..846b30904 100644 --- a/src/metrics.js +++ b/src/metrics.js @@ -215,7 +215,7 @@ async function sendToGraphite() { // the datapoint is (value, timestamp) https://graphite.readthedocs.io/en/latest/ async function getGraphiteUrl() { const [error, result] = await safe(docker.inspect('graphite')); - if (error && error.reason === BoxError.NOT_FOUND) return { status: services.SERVICE_STATUS_STOPPED }; + if (error && error.reason === BoxError.NOT_FOUND) return { status: services.SERVICE_STATUS_ERROR }; if (error) throw error; const ip = safe.query(result, 'NetworkSettings.Networks.cloudron.IPAddress', null); diff --git a/src/services.js b/src/services.js index 274b3da40..cf07234f6 100644 --- a/src/services.js +++ b/src/services.js @@ -37,6 +37,8 @@ const shell = shellModule('services'); const SERVICE_STATUS_STARTING = 'starting'; const SERVICE_STATUS_ACTIVE = 'active'; const SERVICE_STATUS_STOPPED = 'stopped'; +const SERVICE_STATUS_IDLE = 'idle'; +const SERVICE_STATUS_ERROR = 'error'; const SERVICE_STATUS_DISABLED = 'disabled'; @@ -102,12 +104,14 @@ async function containerStatus(containerName) { assert.strictEqual(typeof containerName, 'string'); const [error, container] = await safe(docker.inspect(containerName)); - if (error && error.reason === BoxError.NOT_FOUND) return { status: SERVICE_STATUS_STOPPED }; + if (error && error.reason === BoxError.NOT_FOUND) return { status: SERVICE_STATUS_ERROR }; if (error) throw error; - const ip = safe.query(container, 'NetworkSettings.Networks.cloudron.IPAddress', null); - if (!ip) return { status: SERVICE_STATUS_STOPPED }; const isRunning = container.State?.Running; + if (!isRunning) return { status: SERVICE_STATUS_STOPPED }; + + const ip = safe.query(container, 'NetworkSettings.Networks.cloudron.IPAddress', null); + if (!ip) return { status: SERVICE_STATUS_ERROR }; const [networkError, response] = await safe(superagent.get(`http://${ip}:3000/healthcheck`) .timeout(20000) @@ -120,7 +124,7 @@ async function containerStatus(containerName) { const stats = result.memory_stats || { usage: 0, limit: 1 }; return { - status: isRunning ? SERVICE_STATUS_ACTIVE : SERVICE_STATUS_STOPPED, + status: SERVICE_STATUS_ACTIVE, memoryUsed: stats.usage, memoryPercent: parseInt(100 * stats.usage / stats.limit), healthcheck: response.body @@ -1455,7 +1459,7 @@ async function teardownTls(app, options) { async function statusTurn() { const [error, container] = await safe(docker.inspect('turn')); - if (error && error.reason === BoxError.NOT_FOUND) return { status: SERVICE_STATUS_STOPPED }; + if (error && error.reason === BoxError.NOT_FOUND) return { status: SERVICE_STATUS_ERROR }; if (error) throw error; const result = await docker.getStats(container.Id, { stream: false }); @@ -1474,7 +1478,7 @@ async function statusTurn() { async function statusDocker() { const [error] = await safe(docker.ping()); - return { status: error ? SERVICE_STATUS_STOPPED: SERVICE_STATUS_ACTIVE }; + return { status: error ? SERVICE_STATUS_ERROR: SERVICE_STATUS_ACTIVE }; } async function restartDocker() { @@ -1484,7 +1488,7 @@ async function restartDocker() { async function statusUnbound() { const [error] = await safe(shell.spawn('systemctl', ['is-active', 'unbound'], { encoding: 'utf8' })); - if (error) return { status: SERVICE_STATUS_STOPPED }; + if (error) return { status: SERVICE_STATUS_ERROR }; const [digError, digResult] = await safe(dig.resolve('ipv4.api.cloudron.io', 'A', { timeout: 10000 })); if (!digError && Array.isArray(digResult) && digResult.length !== 0) return { status: SERVICE_STATUS_ACTIVE }; @@ -1500,7 +1504,7 @@ async function restartUnbound() { async function statusNginx() { const [error] = await safe(shell.spawn('systemctl', ['is-active', 'nginx'], { encoding: 'utf8' })); - return { status: error ? SERVICE_STATUS_STOPPED : SERVICE_STATUS_ACTIVE }; + return { status: error ? SERVICE_STATUS_ERROR : SERVICE_STATUS_ACTIVE }; } async function restartNginx() { @@ -1510,7 +1514,7 @@ async function restartNginx() { async function statusGraphite() { const [error, container] = await safe(docker.inspect('graphite')); - if (error && error.reason === BoxError.NOT_FOUND) return { status: SERVICE_STATUS_STOPPED }; + if (error && error.reason === BoxError.NOT_FOUND) return { status: SERVICE_STATUS_ERROR }; if (error) throw error; const ip = safe.query(container, 'NetworkSettings.Networks.cloudron.IPAddress', null); @@ -1647,6 +1651,10 @@ async function getServiceStatus(id) { result.error = status.error || null; result.healthcheck = status.healthcheck || null; + if (result.status === SERVICE_STATUS_STOPPED && LAZY_SERVICES.includes(name)) { + result.status = SERVICE_STATUS_IDLE; + } + result.config = await getServiceConfig(id); if (!result.config.memoryLimit && service.defaultMemoryLimit) { @@ -2352,5 +2360,7 @@ export default { SERVICE_STATUS_STARTING, SERVICE_STATUS_ACTIVE, SERVICE_STATUS_STOPPED, + SERVICE_STATUS_IDLE, + SERVICE_STATUS_ERROR, SERVICE_STATUS_DISABLED, };