diff --git a/usr/lib/dooke/_docker.py b/usr/lib/dooke/_docker.py index 580dbc9..b81804f 100644 --- a/usr/lib/dooke/_docker.py +++ b/usr/lib/dooke/_docker.py @@ -2,7 +2,7 @@ # @author yutent # @date 2024/01/15 15:38:34 -import subprocess, json +import subprocess, json, re from datetime import datetime def toISOTime(time_str): @@ -59,10 +59,14 @@ class Docker: out = subprocess.run(cmd, shell=True, stdout=subprocess.PIPE) - out = out.stdout.decode().strip().split("\n") - out = [json.loads(it) for it in out] + out = json.loads(out.stdout.decode().strip()) - return out + return [{ + "name": it['Name'], + 'config': it['ConfigFiles'], + 'status': it['Status'], + } for it in out] + def networks(self): cmd = 'docker network ls --format=json' @@ -72,9 +76,20 @@ class Docker: out = out.stdout.decode().strip().split("\n") out = [json.loads(it) for it in out] - return out + # return out + return [{ + "id": it['ID'], + "name": it['Name'], + "driver": it['Driver'], + 'scope': it['Scope'], + 'ipv6': it['IPv6'], + 'labels': it['Labels'], + 'internal': it['Internal'], + 'created': toISOTime(re.sub(r'\.\d+', '', it['CreatedAt'])), + } for it in out] - def volumns(self): + + def volumes(self): cmd = 'docker volume ls --format=json' out = subprocess.run(cmd, shell=True, stdout=subprocess.PIPE) @@ -82,13 +97,75 @@ class Docker: out = out.stdout.decode().strip().split("\n") out = [json.loads(it) for it in out] - return out + return [{ + "name": it['Name'], + "driver": it['Driver'], + 'scope': it['Scope'], + 'availability': it['Availability'], + 'mountpoint': it['Mountpoint'], + 'size': it['Size'], + 'status': it['Status'], + } for it in out] + + + def stop_service(self, config = ''): + cmd = 'docker compose -f ' + config + ' stop' + out = subprocess.run(cmd, shell=True, stdout=subprocess.PIPE,stderr=subprocess.PIPE) + + return out.stderr.decode().strip() + + + def prune_volumes(self): + cmd = 'docker volume prune -a -f' + out = subprocess.run(cmd, shell=True, stdout=subprocess.PIPE,stderr=subprocess.PIPE) + + return out.stderr.decode().strip() + + + def rm_volume(self, name = ''): + cmd = 'docker volume rm ' + name + out = subprocess.run(cmd, shell=True, stdout=subprocess.PIPE,stderr=subprocess.PIPE) + + return out.stderr.decode().strip() + + + def prune_networks(self): + cmd = 'docker network prune -f' + out = subprocess.run(cmd, shell=True, stdout=subprocess.PIPE,stderr=subprocess.PIPE) + + return out.stderr.decode().strip() + + + def rm_network(self, id = ''): + cmd = 'docker network rm ' + id + out = subprocess.run(cmd, shell=True, stdout=subprocess.PIPE,stderr=subprocess.PIPE) + + return out.stderr.decode().strip() + + + def start(self, id = ''): + cmd = 'docker start ' + id + out = subprocess.run(cmd, shell=True, stdout=subprocess.PIPE,stderr=subprocess.PIPE) + print('stdout', out.stdout.decode()) + print('stderr', out.stderr.decode()) + return out.stderr.decode().strip() + + + + def stop(self, id = ''): + cmd = 'docker stop ' + id + out = subprocess.run(cmd, shell=True, stdout=subprocess.PIPE,stderr=subprocess.PIPE) + print('stdout', out.stdout.decode()) + print('stderr', out.stderr.decode()) + return out.stderr.decode().strip() + def rm(self, id = ''): cmd = 'docker rm ' + id out = subprocess.run(cmd, shell=True, stdout=subprocess.PIPE,stderr=subprocess.PIPE) return out.stderr.decode().strip() + def rmi(self, id = ''): cmd = 'docker rmi ' + id out = subprocess.run(cmd, shell=True, stdout=subprocess.PIPE,stderr=subprocess.PIPE) diff --git a/usr/lib/dooke/dooke.py b/usr/lib/dooke/dooke.py index 7fe3a57..dedaded 100755 --- a/usr/lib/dooke/dooke.py +++ b/usr/lib/dooke/dooke.py @@ -11,6 +11,8 @@ from webengine.gtk3 import WebEngine, create_setting, create_hmr_server, create_ from _window import Window from _docker import Docker +# from utils import run_async + APP_ID = 'fun.wkit.dooke' __dir__ = os.path.dirname(os.path.realpath(__file__)) @@ -48,7 +50,7 @@ class Application(Gtk.Application): def bridge_handler(self, ev, params): _error = None output = None - print(ev, params) + # print(ev, params) match ev: case 'docker': action = params['action'] @@ -63,12 +65,29 @@ class Application(Gtk.Application): case 'services': output = client.services() + case 'service-stop': + _error = client.stop_service(params.get('config')) - case 'volumns': - output = client.volumns() + + case 'volumes': + output = client.volumes() + case 'volume-prune': + _error = client.prune_volumes() + case 'volume-rm': + _error = client.rm_volume(params.get('name')) case 'networks': output = client.networks() + case 'network-prune': + _error = client.prune_networks() + case 'network-rm': + _error = client.rm_network(params.get('id')) + + case 'start': + _error = client.start(params.get('id')) + + case 'stop': + _error = client.stop(params.get('id')) case 'rm': _error = client.rm(params.get('id')) diff --git a/usr/lib/dooke/utils.py b/usr/lib/dooke/utils.py new file mode 100644 index 0000000..7468f3d --- /dev/null +++ b/usr/lib/dooke/utils.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 + + +import gi, threading +from gi.repository import GObject + + + +# 定义一个异步修饰器, 用于在子线程中运行一些会阻塞主线程的任务 +def run_async(func): + def wrapper(*args, **kwargs): + thread = threading.Thread(target=func, args=args, kwargs=kwargs) + thread.daemon = True + thread.start() + return thread + return wrapper + +# 类型js的settimeout的修饰器 +def set_timeout(timeout = 0.5): + def decorator(callback): + def wrapper(*args): + t = threading.Timer(timeout, callback, args=args) + t.start() + return t + return wrapper + + return decorator + + +# 定义一个修饰器, 用于将当前方法转到主线程中运行 (子线程中调用方法时) +def idle(func): + def wrapper(*args): + GObject.idle_add(func, *args) + return wrapper + diff --git a/usr/lib/dooke/webapp/app.js b/usr/lib/dooke/webapp/app.js index f996961..85554fa 100644 --- a/usr/lib/dooke/webapp/app.js +++ b/usr/lib/dooke/webapp/app.js @@ -45,6 +45,7 @@ createApp({ router-view { flex: 1; + width: calc(100vw - 144px); height: 100; } ` diff --git a/usr/lib/dooke/webapp/router.js b/usr/lib/dooke/webapp/router.js index 8306cf0..74747fb 100644 --- a/usr/lib/dooke/webapp/router.js +++ b/usr/lib/dooke/webapp/router.js @@ -9,6 +9,8 @@ import { createRouter, createWebHistory } from 'wkitd' import './views/home.js' import './views/images.js' import './views/volumes.js' +import './views/networks.js' +import './views/services.js' export default createRouter({ history: createWebHistory(), @@ -24,6 +26,14 @@ export default createRouter({ { path: '/volumes', name: 'wc-volumes' + }, + { + path: '/networks', + name: 'wc-networks' + }, + { + path: '/services', + name: 'wc-services' } ] }) diff --git a/usr/lib/dooke/webapp/store.js b/usr/lib/dooke/webapp/store.js index d9bff94..77e22aa 100644 --- a/usr/lib/dooke/webapp/store.js +++ b/usr/lib/dooke/webapp/store.js @@ -34,5 +34,10 @@ export default createStore({ name: '网络管理', icon: 'share' } - ] + ], + containers: [], + images: [], + volumes: [], + services: [], + networks: [] }) diff --git a/usr/lib/dooke/webapp/utils/index.js b/usr/lib/dooke/webapp/utils/index.js index 425c701..7f1fdd5 100644 --- a/usr/lib/dooke/webapp/utils/index.js +++ b/usr/lib/dooke/webapp/utils/index.js @@ -17,8 +17,8 @@ export const docker = { return native.handler('docker', { action: 'images' }) }, - volumns() { - return native.handler('docker', { action: 'volumns' }) + volumes() { + return native.handler('docker', { action: 'volumes' }) }, networks() { @@ -35,5 +35,37 @@ export const docker = { rmi(id) { return native.handler('docker', { action: 'rmi', id }) + }, + + start(id) { + return native.handler('docker', { action: 'start', id }) + }, + + stop(id) { + return native.handler('docker', { action: 'stop', id }) + }, + + service: { + stop(config) { + return native.handler('docker', { action: 'service-stop', config }) + } + }, + + volume: { + prune() { + return native.handler('docker', { action: 'volume-prune' }) + }, + rm(name) { + return native.handler('docker', { action: 'volume-rm', name }) + } + }, + + network: { + prune() { + return native.handler('docker', { action: 'network-prune' }) + }, + rm(id) { + return native.handler('docker', { action: 'network-rm', id }) + } } } diff --git a/usr/lib/dooke/webapp/views/home.js b/usr/lib/dooke/webapp/views/home.js index 3d7b4a1..dfc4d9e 100644 --- a/usr/lib/dooke/webapp/views/home.js +++ b/usr/lib/dooke/webapp/views/home.js @@ -7,10 +7,14 @@ import { html, css, Component, classMap, nextTick, outsideClick } from 'wkit' import { docker } from '../utils/index.js' +const STATE_RUNNING = '🟢' +const STATE_CREATED = '⚪' +const STATE_STOPPED = '🔴' +const ACTION_STOP = '⏹' +const ACTION_START = '⏸' + class Home extends Component { - static props = { - containers: [] - } + static props = {} static styles = [ css` @@ -142,16 +146,20 @@ class Home extends Component { list.forEach(it => { it.image = it.image.split(':') - it.cmd = it.cmd.replace(/^"|"$/, '') + it.cmd = it.cmd.replace(/^"|"$/g, '') it.port = it.port .replace('0.0.0.0:', '') .replace(':::', '') .replace(/\/tcp/g, '') it.state = - it.state === 'running' ? '🟢' : it.state === 'created' ? '⚪' : '🔴' + it.state === 'running' + ? STATE_RUNNING + : it.state === 'created' + ? STATE_CREATED + : STATE_STOPPED }) - this.containers = list + this.$store.containers = list } async #filter(ev) { @@ -170,13 +178,23 @@ class Home extends Component { this.$requestUpdate() } + async #toggleStat(item) { + // + if (item.state === STATE_RUNNING) { + await docker.stop(item.id) + } else { + await docker.start(item.id) + } + this.#fetch() + } + async #remove(item) { await docker.rm(item.id) this.#fetch() } render() { - let list = this.containers + let list = this.$store.containers let txt = this.#input if (txt) { @@ -232,9 +250,20 @@ class Home extends Component { ${it.state}
- ${it.state === '🟢' ? '⏹' : '⏸'} this.#toggleStat(it)} > + + ${it.state === STATE_RUNNING ? ACTION_STOP : ACTION_START} + + this.#remove(it)} diff --git a/usr/lib/dooke/webapp/views/images.js b/usr/lib/dooke/webapp/views/images.js index 6e99dee..ca2c05f 100644 --- a/usr/lib/dooke/webapp/views/images.js +++ b/usr/lib/dooke/webapp/views/images.js @@ -8,9 +8,7 @@ import { html, css, Component, classMap, nextTick, outsideClick } from 'wkit' import { docker } from '../utils/index.js' class Images extends Component { - static props = { - images: [] - } + static props = {} static styles = [ css` @@ -96,6 +94,16 @@ class Images extends Component { text-overflow: ellipsis; color: var(--color-blue-1); } + .action { + cursor: pointer; + + &.red { + color: var(--color-red-1); + } + &.green { + color: var(--color-teal-1); + } + } } .thead { @@ -117,7 +125,7 @@ class Images extends Component { async #fetch() { let list = await docker.images() - this.images = list + this.$store.images = list } #filter(ev) { @@ -135,7 +143,7 @@ class Images extends Component { } render() { - let list = this.images + let list = this.$store.images let txt = this.#input if (txt) { list = list.filter(it => it.name.includes(txt)) diff --git a/usr/lib/dooke/webapp/views/networks.js b/usr/lib/dooke/webapp/views/networks.js new file mode 100644 index 0000000..a568ffb --- /dev/null +++ b/usr/lib/dooke/webapp/views/networks.js @@ -0,0 +1,202 @@ +/** + * {} + * @author yutent + * @date 2023/08/08 18:19:17 + */ +import { html, css, Component, classMap, nextTick, outsideClick } from 'wkit' + +import { docker } from '../utils/index.js' + +class Networks extends Component { + static props = {} + + static styles = [ + css` + :host { + display: block; + width: 100%; + height: 100%; + color: var(--color-dark-1); + background: #fff; + } + .visible { + display: block; + } + + ul li { + list-style: none; + } + + .noselect { + -webkit-touch-callout: none; + -webkit-user-select: none; + user-select: none; + } + `, + + css` + .main { + display: flex; + flex-direction: column; + height: 100%; + padding: 16px; + } + .main .toolbar { + height: 48px; + border-bottom: 1px solid var(--color-plain-3); + } + .main .list { + overflow: auto; + flex: 1; + display: flex; + flex-direction: column; + gap: 3px; + width: 100%; + height: 100%; + padding: 12px 0; + } + .item, + .thead { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 12px; + width: 100%; + height: 48px; + padding: 0 12px; + line-height: 1.25; + border-radius: 24px; + + --wc-icon-size: 16px; + } + .field { + overflow: hidden; + flex: 1; + + &.flex-2 { + flex: 2; + } + + &.name { + display: flex; + flex-direction: column; + } + + &.center { + text-align: center; + } + + .text-ell { + overflow: hidden; + display: block; + width: 100%; + white-space: nowrap; + text-overflow: ellipsis; + color: var(--color-blue-1); + } + + .action { + cursor: pointer; + + &.red { + color: var(--color-red-1); + } + &.green { + color: var(--color-teal-1); + } + } + } + + .thead { + color: var(--color-dark-2); + } + .item { + &:hover { + background: var(--color-plain-1); + } + } + ` + ] + + async mounted() { + this.#fetch() + } + + async #fetch() { + let list = await docker.networks() + this.$store.networks = list + } + + async #remove(item) { + try { + await docker.network.rm(item.id) + this.#fetch() + } catch (err) { + layer.toast(err, 'error') + } + } + + async #clearUnUse(ev) { + try { + await layer.confirm('是否要清除本地未使用的网络?') + await docker.network.prune() + layer.toast('清除成功', 'success') + this.#fetch() + } catch (e) {} + } + + render() { + let list = this.$store.networks + + return html` +
+ + + 清除未使用网络 + + + +
    +
  • + 网络/ID + driver + IPv6 + 创建时间 + 操作 +
  • + ${list.map( + it => html` +
  • +
    + ${it.name} + ${it.id} +
    +
    ${it.driver}
    +
    ${it.ipv6}
    +
    + +
    +
    + this.#remove(it)} + > + 🗑 + +
    +
  • + ` + )} +
+
+ ` + } +} + +Networks.reg('networks') diff --git a/usr/lib/dooke/webapp/views/services.js b/usr/lib/dooke/webapp/views/services.js new file mode 100644 index 0000000..bc8e7bc --- /dev/null +++ b/usr/lib/dooke/webapp/views/services.js @@ -0,0 +1,204 @@ +/** + * {} + * @author yutent + * @date 2023/08/08 18:19:17 + */ +import { html, css, Component, classMap, nextTick, outsideClick } from 'wkit' + +import { docker } from '../utils/index.js' + +class Services extends Component { + static props = {} + + static styles = [ + css` + :host { + display: block; + width: 100%; + height: 100%; + color: var(--color-dark-1); + background: #fff; + } + .visible { + display: block; + } + + ul li { + list-style: none; + } + + .noselect { + -webkit-touch-callout: none; + -webkit-user-select: none; + user-select: none; + } + `, + + css` + .main { + display: flex; + flex-direction: column; + height: 100%; + padding: 16px; + } + .main .toolbar { + height: 48px; + border-bottom: 1px solid var(--color-plain-3); + } + .main .list { + overflow: auto; + flex: 1; + display: flex; + flex-direction: column; + gap: 3px; + width: 100%; + height: 100%; + padding: 12px 0; + } + .item, + .thead { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 12px; + width: 100%; + height: 48px; + padding: 0 12px; + line-height: 1.25; + border-radius: 24px; + + --wc-icon-size: 16px; + } + .field { + overflow: hidden; + flex: 1; + + &.flex-2 { + flex: 2; + } + + &.name { + display: flex; + flex-direction: column; + } + + &.center { + text-align: center; + } + + .text-ell { + overflow: hidden; + display: block; + width: 100%; + white-space: nowrap; + text-overflow: ellipsis; + color: var(--color-blue-1); + } + .action { + cursor: pointer; + + &.red { + color: var(--color-red-1); + } + &.green { + color: var(--color-teal-1); + } + } + } + + .thead { + color: var(--color-dark-2); + } + .item { + &:hover { + background: var(--color-plain-1); + } + } + ` + ] + + #input = '' + + async mounted() { + this.#fetch() + } + + async #fetch() { + let list = await docker.services() + this.$store.services = list + } + + #filter(ev) { + let txt = ev.currentTarget.value.trim() + if (this.#input === txt) { + return + } + this.#input = txt + this.$requestUpdate() + } + + async #stop(item) { + let { services } = this.$store + try { + await docker.service.stop(item.config) + } catch (err) { + if (err.includes('Stopped')) { + layer.toast('服务已停止', 'success') + this.#fetch() + } else { + layer.toast(err, 'error') + } + } + } + + render() { + let list = this.$store.services + let txt = this.#input + if (txt) { + list = list.filter(it => it.name.includes(txt)) + } + + return html` +
+ + + + +
    +
  • + 服务 + 配置文件 + 状态 + 操作 +
  • + ${list.map( + it => html` +
  • +
    + ${it.name} +
    +
    ${it.config}
    +
    ${it.status}
    +
    + this.#stop(it)} + > + + +
    +
  • + ` + )} +
+
+ ` + } +} + +Services.reg('services') diff --git a/usr/lib/dooke/webapp/views/volumes.js b/usr/lib/dooke/webapp/views/volumes.js index e69de29..0c0835f 100644 --- a/usr/lib/dooke/webapp/views/volumes.js +++ b/usr/lib/dooke/webapp/views/volumes.js @@ -0,0 +1,211 @@ +/** + * {} + * @author yutent + * @date 2023/08/08 18:19:17 + */ +import { html, css, Component, classMap, nextTick, outsideClick } from 'wkit' + +import { docker } from '../utils/index.js' + +class Volumes extends Component { + static props = {} + + static styles = [ + css` + :host { + display: block; + width: 100%; + height: 100%; + color: var(--color-dark-1); + background: #fff; + } + .visible { + display: block; + } + + ul li { + list-style: none; + } + + .noselect { + -webkit-touch-callout: none; + -webkit-user-select: none; + user-select: none; + } + `, + + css` + .main { + display: flex; + flex-direction: column; + height: 100%; + padding: 16px; + } + + .main .toolbar { + height: 48px; + border-bottom: 1px solid var(--color-plain-3); + } + + .main .list { + overflow: auto; + flex: 1; + display: flex; + flex-direction: column; + gap: 3px; + width: 100%; + height: 100%; + padding: 12px 0; + } + .item, + .thead { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 12px; + width: 100%; + height: 48px; + padding: 0 12px; + line-height: 1.25; + border-radius: 24px; + + --wc-icon-size: 16px; + } + .field { + overflow: hidden; + flex: 1; + + &.flex-2 { + flex: 2; + } + + &.name { + display: flex; + flex-direction: column; + } + + &.center { + text-align: center; + } + + .text-ell { + overflow: hidden; + display: block; + width: 100%; + white-space: nowrap; + text-overflow: ellipsis; + color: var(--color-blue-1); + } + + .action { + cursor: pointer; + + &.red { + color: var(--color-red-1); + } + &.green { + color: var(--color-teal-1); + } + } + } + + .thead { + color: var(--color-dark-2); + } + .item { + &:hover { + background: var(--color-plain-1); + } + } + ` + ] + + async mounted() { + this.#fetch() + } + + async #fetch() { + let list = await docker.volumes() + this.$store.volumes = list + } + + async #remove(item) { + try { + await docker.volume.rm(item.name) + this.#fetch() + } catch (err) { + layer.toast(err, 'error') + } + } + + async #clearUnUse(ev) { + try { + await layer.confirm( + '是否要清除本地未使用的磁盘?
(此操作仅是清除docker的挂载, 不会删除本地文件)' + ) + await docker.volume.prune() + layer.toast('清除成功', 'success') + this.#fetch() + } catch (e) {} + } + + render() { + let list = this.$store.volumes + + return html` +
+ + + 清除未使用磁盘 + + + +
    +
  • + name + Scope + 挂载点 + 大小 + 状态 + 操作 +
  • + ${list.map( + it => html` +
  • +
    + + ${it.name} + +
    +
    ${it.scope}
    +
    + + ${it.mountpoint} + +
    +
    ${it.size}
    +
    ${it.status}
    +
    + this.#remove(it)} + > + 🗑 + +
    +
  • + ` + )} +
+
+ ` + } +} + +Volumes.reg('volumes')