Compare commits
26 commits
Author | SHA1 | Date | |
---|---|---|---|
c5f1eed9cc | |||
97345e9e04 | |||
07b0d31543 | |||
a84fa83cf2 | |||
58a8402a91 | |||
237fb2c6c4 | |||
7cdcc7c6c1 | |||
8e5b2c53ad | |||
b4394c67ed | |||
|
c98742a9cc | ||
972bef1dbf | |||
7cae48d0d6 | |||
be08ef4b9c | |||
|
90332d5041 | ||
|
7aa0249136 | ||
|
e45854e9a9 | ||
|
3b31f57972 | ||
|
3c91fcf821 | ||
|
3e5ab6f290 | ||
|
4abcdfcdcb | ||
|
65893e806c | ||
|
b248a1db49 | ||
|
725ceb4a1a | ||
|
61479f398e | ||
|
3a9a913207 | ||
|
d2ecac2d6b |
4 changed files with 324 additions and 145 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
*.pyc
|
||||||
|
|
42
EntryDialog.py
Normal file
42
EntryDialog.py
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
# This code taken from:
|
||||||
|
# http://stackoverflow.com/questions/8290740/simple-versatile-and-re-usable-entry-dialog-sometimes-referred-to-as-input-dia
|
||||||
|
|
||||||
|
import gi
|
||||||
|
gi.require_version("Gtk", "3.0")
|
||||||
|
from gi.repository import Gtk as gtk
|
||||||
|
|
||||||
|
|
||||||
|
class EntryDialog(gtk.MessageDialog):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
'''
|
||||||
|
Creates a new EntryDialog. Takes all the arguments of the usual
|
||||||
|
MessageDialog constructor plus one optional named argument
|
||||||
|
"default_value" to specify the initial contents of the entry.
|
||||||
|
'''
|
||||||
|
if 'default_value' in kwargs:
|
||||||
|
default_value = kwargs['default_value']
|
||||||
|
del kwargs['default_value']
|
||||||
|
else:
|
||||||
|
default_value = ''
|
||||||
|
super(EntryDialog, self).__init__(*args, **kwargs)
|
||||||
|
entry = gtk.Entry()
|
||||||
|
entry.set_text(str(default_value))
|
||||||
|
entry.connect("activate",
|
||||||
|
lambda ent, dlg, resp: dlg.response(resp),
|
||||||
|
self, gtk.ResponseType.OK)
|
||||||
|
self.vbox.pack_end(entry, True, True, 0)
|
||||||
|
self.vbox.show_all()
|
||||||
|
self.entry = entry
|
||||||
|
|
||||||
|
def set_value(self, text):
|
||||||
|
self.entry.set_text(text)
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
result = super(EntryDialog, self).run()
|
||||||
|
if result == gtk.ResponseType.OK:
|
||||||
|
text = self.entry.get_text()
|
||||||
|
else:
|
||||||
|
text = None
|
||||||
|
return text
|
70
README.md
70
README.md
|
@ -1,22 +1,13 @@
|
||||||
crussh: a modern cssh replacement
|
# crussh: a modern cssh replacement
|
||||||
=================================
|
Available via [radicle](https://radicle.xyz/) at: `rad:z3BhWhapyVBzzuBicAyCcmMhv1yEe`
|
||||||
|
|
||||||
Backstory
|
## What are this?
|
||||||
---------
|
|
||||||
|
|
||||||
For anyone who needs to administrate clusters of many machines,
|
For anyone who needs to administrate clusters of many machines,
|
||||||
[clusterssh](http://sourceforge.net/projects/clusterssh/) has long been a
|
[clusterssh](http://sourceforge.net/projects/clusterssh/) has long been a
|
||||||
fallback for when the rest of your automation tools aren't working.
|
fallback for when the rest of your automation tools aren't working.
|
||||||
|
|
||||||
However, cssh has a number of deficiencies in modern environments:
|
crussh aims to be a simple replacement for cssh with the following improvements:
|
||||||
|
|
||||||
- Doesn't play nice with window placement of modern window managers.
|
|
||||||
- Doesn't play nice with modern toolkits' copy and paste behaviour.
|
|
||||||
- Gets a bit screwy when there's more terminals than can fit on-screen.
|
|
||||||
- Doesn't support nice antialiased fonts.
|
|
||||||
|
|
||||||
crussh aims to be a simple replacement for cssh that corrects these
|
|
||||||
problems. It does so with the following features:
|
|
||||||
|
|
||||||
- Uses a single window to hold multiple terminals.
|
- Uses a single window to hold multiple terminals.
|
||||||
- Intelligently tiles terminals to fit available window size.
|
- Intelligently tiles terminals to fit available window size.
|
||||||
|
@ -24,35 +15,56 @@ problems. It does so with the following features:
|
||||||
- Never resizes a terminal smaller than 80x24 characters.
|
- Never resizes a terminal smaller than 80x24 characters.
|
||||||
- Uses GTK and the VTE widget to provide modern, anti-aliased terminals.
|
- Uses GTK and the VTE widget to provide modern, anti-aliased terminals.
|
||||||
|
|
||||||
Install
|
## Install
|
||||||
-------
|
|
||||||
|
|
||||||
The install process is very simple on most distros:
|
The install process is very simple on most distros:
|
||||||
|
|
||||||
- Install python2, python-gtk2, and python-vte.
|
* Install python3 and python3-gi.
|
||||||
- Clone and symlink to your bin dir.
|
* Clone and symlink to your bin dir:
|
||||||
git clone https://github.com/unit3/crussh.git
|
```bash
|
||||||
ln -s crussh/crussh.py ~/bin/crussh
|
git clone https://github.com/nergdron/crussh.git
|
||||||
- Run "crussh HOST [HOST ...]"
|
ln -s $(pwd)/crussh/crussh.py ~/bin/crussh
|
||||||
|
```
|
||||||
|
|
||||||
Usage Tips
|
Run ```crussh HOST [HOST ...]```
|
||||||
----------
|
|
||||||
|
|
||||||
Doing a clustered paste isn't completely obvious. The following methods will work:
|
## Examples
|
||||||
|
|
||||||
|
Basic usage is covered via the builtin help, which you can get by running
|
||||||
|
```crussh -h```. This section covers some common use cases.
|
||||||
|
|
||||||
|
To connect to a list of hosts in a file:
|
||||||
|
```bash
|
||||||
|
crussh $(cat hostlist.txt)
|
||||||
|
```
|
||||||
|
|
||||||
|
To use a custom login name, public key, or other SSH client options:
|
||||||
|
```bash
|
||||||
|
crussh -l someuser -i ~/.ssh/myotherkey -- host [host ...]
|
||||||
|
```
|
||||||
|
|
||||||
|
To do something other than ssh, such as edit a bunch of files in parallel:
|
||||||
|
```bash
|
||||||
|
crussh -e nano *.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage Tips
|
||||||
|
|
||||||
|
Doing a clustered paste isn't completely obvious. The following methods will
|
||||||
|
work, after making sure you're clicked into the text entry box at the bottom of
|
||||||
|
the window:
|
||||||
|
|
||||||
- middle click or shift-insert to paste the X11 selection buffer.
|
- middle click or shift-insert to paste the X11 selection buffer.
|
||||||
- control-shift-v to paste the GTK/GNOME clipboard.
|
- control-shift-v to paste the GTK/GNOME clipboard.
|
||||||
|
|
||||||
Bugs & TODO
|
## Bugs & To Do
|
||||||
-----------
|
|
||||||
|
|
||||||
To see current issues, report problems, and see plans for features,
|
To see current issues, report problems, and see plans for features,
|
||||||
see the [crussh GitHub issues page](https://github.com/unit3/crussh/issues).
|
see the [crussh GitHub issues page](https://github.com/nergdron/crussh/issues).
|
||||||
|
|
||||||
Copyright and License
|
## Copyright and License
|
||||||
---------------------
|
|
||||||
|
|
||||||
crussh is copyright 2012 by Graeme Humphries <graeme@sudo.ca>.
|
crussh is copyright 2012-2019 by Tessa Nordgren <tessa@sudo.ca>.
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
This program is free software: you can redistribute it and/or modify
|
||||||
it under the terms of the GNU General Public License as published by
|
it under the terms of the GNU General Public License as published by
|
||||||
|
|
341
crussh.py
341
crussh.py
|
@ -1,36 +1,31 @@
|
||||||
#!/usr/bin/env python
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
# A cssh replacement written in Python / GTK.
|
# A cssh replacement written in Python / GTK.
|
||||||
# (c)2012 - Graeme Humphries <graeme@sudo.ca>.
|
# (c)2012-2019 - Tessa Nordgren <tessa@sudo.ca>.
|
||||||
# Released under the GPL, version 3: http://www.gnu.org/licenses/
|
# Released under the GPL, version 3: http://www.gnu.org/licenses/
|
||||||
|
|
||||||
# Requires: python-gtk2 python-vte
|
import gi
|
||||||
|
gi.require_version("Gtk", "3.0")
|
||||||
import sys
|
from gi.repository import Gtk as gtk
|
||||||
|
from gi.repository import Gdk as gdk
|
||||||
|
from gi.repository import GLib as glib
|
||||||
|
from gi.repository.Pango import FontDescription
|
||||||
|
gi.require_version('Vte', '2.91')
|
||||||
|
from gi.repository import Vte as vte
|
||||||
import math
|
import math
|
||||||
import json
|
import yaml
|
||||||
import os.path
|
import os.path
|
||||||
|
from EntryDialog import EntryDialog
|
||||||
try:
|
from collections import OrderedDict
|
||||||
import gtk
|
|
||||||
except:
|
|
||||||
print >>sys.stderr, "Missing Python GTK2 bindings."
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
try:
|
|
||||||
import vte
|
|
||||||
except:
|
|
||||||
print >>sys.stderr, "Missing Python VTE bindings."
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
### Config Dialog ###
|
# Config Dialog #
|
||||||
class CruSSHConf:
|
class CruSSHConf:
|
||||||
### State Vars ###
|
# State Vars #
|
||||||
Config = {}
|
Config = {}
|
||||||
MainWin = gtk.Window()
|
MainWin = gtk.Window()
|
||||||
|
|
||||||
### Signal Hooks ###
|
# Signal Hooks #
|
||||||
def save_hook(self, discard, save_func):
|
def save_hook(self, discard, save_func):
|
||||||
self.MainWin.destroy()
|
self.MainWin.destroy()
|
||||||
if save_func is not None:
|
if save_func is not None:
|
||||||
|
@ -45,46 +40,65 @@ class CruSSHConf:
|
||||||
def height_hook(self, spinbutton):
|
def height_hook(self, spinbutton):
|
||||||
self.Config["min-height"] = spinbutton.get_value_as_int()
|
self.Config["min-height"] = spinbutton.get_value_as_int()
|
||||||
|
|
||||||
### GUI Objects ###
|
def maximized_hook(self, checkbutton):
|
||||||
|
self.Config["start-maximized"] = checkbutton.get_active()
|
||||||
|
|
||||||
|
# GUI Objects #
|
||||||
def initGUI(self, save_func=None):
|
def initGUI(self, save_func=None):
|
||||||
self.MainWin.set_modal(True)
|
self.MainWin.set_modal(True)
|
||||||
self.MainWin.props.allow_grow = False
|
|
||||||
|
|
||||||
MainBox = gtk.VBox(spacing=5)
|
MainBox = gtk.VBox(spacing=5)
|
||||||
MainBox.props.border_width = 5
|
MainBox.props.border_width = 5
|
||||||
self.MainWin.add(MainBox)
|
self.MainWin.add(MainBox)
|
||||||
|
|
||||||
|
GlobalConfFrame = gtk.Frame(label="Global Options")
|
||||||
|
GlobalConfTable = gtk.Table(1, 2)
|
||||||
|
GlobalConfTable.props.border_width = 5
|
||||||
|
GlobalConfTable.props.row_spacing = 5
|
||||||
|
GlobalConfTable.props.column_spacing = 5
|
||||||
|
GlobalConfFrame.add(GlobalConfTable)
|
||||||
|
MainBox.pack_start(GlobalConfFrame, False, False, 0)
|
||||||
|
|
||||||
|
GlobalConfTable.attach(gtk.Label("Start Maximized:"), 1, 2, 1, 2, gtk.AttachOptions.EXPAND)
|
||||||
|
MaximizedConf = gtk.CheckButton()
|
||||||
|
MaximizedConf.set_active(self.Config["start-maximized"])
|
||||||
|
MaximizedConf.connect("toggled", self.maximized_hook)
|
||||||
|
GlobalConfTable.attach(MaximizedConf, 2, 3, 1, 2, gtk.AttachOptions.EXPAND)
|
||||||
|
|
||||||
TermConfFrame = gtk.Frame(label="Terminal Options")
|
TermConfFrame = gtk.Frame(label="Terminal Options")
|
||||||
TermConfTable = gtk.Table(3, 2)
|
TermConfTable = gtk.Table(3, 2)
|
||||||
TermConfTable.props.border_width = 5
|
TermConfTable.props.border_width = 5
|
||||||
TermConfTable.props.row_spacing = 5
|
TermConfTable.props.row_spacing = 5
|
||||||
TermConfTable.props.column_spacing = 5
|
TermConfTable.props.column_spacing = 5
|
||||||
TermConfFrame.add(TermConfTable)
|
TermConfFrame.add(TermConfTable)
|
||||||
MainBox.pack_start(TermConfFrame)
|
MainBox.pack_start(TermConfFrame, False, False, 0)
|
||||||
|
|
||||||
TermConfTable.attach(gtk.Label("Font:"), 1, 2, 1, 2, gtk.EXPAND)
|
TermConfTable.attach(gtk.Label("Font:"), 1, 2, 1, 2, gtk.AttachOptions.EXPAND)
|
||||||
FontConf = gtk.FontButton(fontname=self.Config["font"])
|
FontConf = gtk.FontButton(font=self.Config["font"])
|
||||||
FontConf.connect("font-set", self.font_hook)
|
FontConf.connect("font-set", self.font_hook)
|
||||||
TermConfTable.attach(FontConf, 2, 3, 1, 2, gtk.EXPAND)
|
TermConfTable.attach(FontConf, 2, 3, 1, 2, gtk.AttachOptions.EXPAND)
|
||||||
|
|
||||||
SizeBox = gtk.HBox()
|
SizeBox = gtk.HBox()
|
||||||
SizeBox.props.spacing = 5
|
SizeBox.props.spacing = 5
|
||||||
TermConfTable.attach(SizeBox, 1, 3, 2, 3)
|
TermConfTable.attach(SizeBox, 1, 3, 2, 3)
|
||||||
SizeBox.pack_start(gtk.Label("Min Width:"), fill=False, expand=False)
|
SizeBox.pack_start(gtk.Label("Min Width:"), False, False, 0)
|
||||||
WidthEntry = gtk.SpinButton(gtk.Adjustment(value=self.Config["min-width"], lower=1, upper=9999, step_incr=1))
|
WidthEntry = gtk.SpinButton()
|
||||||
|
# gtk.Adjustment(value=self.Config["min-width"], lower=1, upper=9999, step_incr=1)
|
||||||
|
WidthEntry.set_range(1, 9999)
|
||||||
WidthEntry.connect("value-changed", self.width_hook)
|
WidthEntry.connect("value-changed", self.width_hook)
|
||||||
SizeBox.pack_start(WidthEntry, fill=False, expand=False)
|
SizeBox.pack_start(WidthEntry, False, False, 0)
|
||||||
SizeBox.pack_start(gtk.Label("Min Height:"), fill=False, expand=False)
|
SizeBox.pack_start(gtk.Label("Min Height:"), False, False, 0)
|
||||||
HeightEntry = gtk.SpinButton(gtk.Adjustment(value=self.Config["min-height"], lower=1, upper=9999, step_incr=1))
|
HeightEntry = gtk.SpinButton()
|
||||||
|
HeightEntry.set_range(1, 9999)
|
||||||
HeightEntry.connect("value-changed", self.height_hook)
|
HeightEntry.connect("value-changed", self.height_hook)
|
||||||
SizeBox.pack_start(HeightEntry, fill=False, expand=False)
|
SizeBox.pack_start(HeightEntry, False, False, 0)
|
||||||
|
|
||||||
ConfirmBox = gtk.HBox(spacing=5)
|
ConfirmBox = gtk.HBox(spacing=5)
|
||||||
CancelButton = gtk.Button(stock=gtk.STOCK_CANCEL)
|
CancelButton = gtk.Button(stock=gtk.STOCK_CANCEL)
|
||||||
ConfirmBox.pack_start(CancelButton, fill=False)
|
ConfirmBox.pack_start(CancelButton, False, False, 0)
|
||||||
SaveButton = gtk.Button(stock=gtk.STOCK_SAVE)
|
SaveButton = gtk.Button(stock=gtk.STOCK_SAVE)
|
||||||
ConfirmBox.pack_start(SaveButton, fill=False)
|
ConfirmBox.pack_start(SaveButton, False, False, 0)
|
||||||
MainBox.pack_start(ConfirmBox, fill=False, expand=False)
|
MainBox.pack_start(ConfirmBox, False, False, 0)
|
||||||
|
|
||||||
# wire up behaviour
|
# wire up behaviour
|
||||||
CancelButton.connect("clicked", lambda discard: self.MainWin.destroy())
|
CancelButton.connect("clicked", lambda discard: self.MainWin.destroy())
|
||||||
|
@ -100,35 +114,94 @@ class CruSSHConf:
|
||||||
self.initGUI(save_func)
|
self.initGUI(save_func)
|
||||||
|
|
||||||
|
|
||||||
### CruSSH! ###
|
# Hosts Mask Dialog #
|
||||||
|
class HostsMask:
|
||||||
|
Terminals = {}
|
||||||
|
MainWin = gtk.Window()
|
||||||
|
|
||||||
|
def toggle_func(self, checkitem, host):
|
||||||
|
self.Terminals[host].copy_input = checkitem.get_active()
|
||||||
|
|
||||||
|
def InitGUI(self):
|
||||||
|
self.MainWin.set_modal(True)
|
||||||
|
|
||||||
|
MainBox = gtk.VBox(spacing=5)
|
||||||
|
MainBox.props.border_width = 5
|
||||||
|
self.MainWin.add(MainBox)
|
||||||
|
|
||||||
|
# determine optimal table dimensions
|
||||||
|
cols = int(math.sqrt(len(self.Terminals)))
|
||||||
|
rows = int(math.ceil(len(self.Terminals) / cols))
|
||||||
|
|
||||||
|
HostsConfFrame = gtk.Frame(label="Active Terminals")
|
||||||
|
HostsConfTable = gtk.Table(rows, cols)
|
||||||
|
HostsConfTable.props.border_width = 5
|
||||||
|
HostsConfTable.props.row_spacing = 5
|
||||||
|
HostsConfTable.props.column_spacing = 5
|
||||||
|
HostsConfFrame.add(HostsConfTable)
|
||||||
|
|
||||||
|
i = 0
|
||||||
|
|
||||||
|
hosts = self.Terminals.keys()
|
||||||
|
for host in hosts:
|
||||||
|
HostTable = gtk.Table(1, 2)
|
||||||
|
HostTable.props.column_spacing = 2
|
||||||
|
HostTable.attach(gtk.Label(host), 0, 1, 0, 1, gtk.AttachOptions.EXPAND, gtk.AttachOptions.EXPAND, 0, 0)
|
||||||
|
HostCheckbox = gtk.CheckButton()
|
||||||
|
HostCheckbox.set_active(self.Terminals[host].copy_input)
|
||||||
|
HostCheckbox.connect("toggled", self.toggle_func, host)
|
||||||
|
HostTable.attach(HostCheckbox, 1, 2, 0, 1, gtk.AttachOptions.EXPAND, gtk.AttachOptions.EXPAND, 0, 0)
|
||||||
|
row = i / cols
|
||||||
|
col = i % cols
|
||||||
|
HostsConfTable.attach(HostTable, col, col+1, row, row+1, gtk.AttachOptions.EXPAND, gtk.AttachOptions.EXPAND, 0, 0)
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
MainBox.pack_start(HostsConfFrame, False, False, 0)
|
||||||
|
|
||||||
|
OkButton = gtk.Button(stock=gtk.STOCK_OK)
|
||||||
|
MainBox.pack_start(OkButton, False, False, 0)
|
||||||
|
|
||||||
|
# wire up behaviour
|
||||||
|
OkButton.connect("clicked", lambda discard: self.MainWin.destroy())
|
||||||
|
|
||||||
|
self.MainWin.show_all()
|
||||||
|
|
||||||
|
def __init__(self, terminals=None):
|
||||||
|
if hosts is not None:
|
||||||
|
self.Terminals = terminals
|
||||||
|
self.InitGUI()
|
||||||
|
|
||||||
|
|
||||||
|
# CruSSH! #
|
||||||
class CruSSH:
|
class CruSSH:
|
||||||
### Config Vars ###
|
# Config Vars #
|
||||||
# config defaults
|
# config defaults
|
||||||
Config = {
|
Config = {
|
||||||
"min-width": 80,
|
"min-width": 80,
|
||||||
"min-height": 24,
|
"min-height": 24,
|
||||||
"font": "Ubuntu Mono Bold 10"
|
"font": "Ubuntu Mono,monospace Bold 10",
|
||||||
|
"start-maximized": True
|
||||||
}
|
}
|
||||||
|
|
||||||
### State Vars ###
|
# State Vars #
|
||||||
Terminals = {}
|
Terminals = OrderedDict()
|
||||||
TermMinWidth = 1
|
TermMinWidth = 1
|
||||||
TermMinHeight = 1
|
TermMinHeight = 1
|
||||||
|
|
||||||
### GUI Objects ###
|
# GUI Objects #
|
||||||
MainWin = gtk.Window()
|
MainWin = gtk.Window()
|
||||||
ScrollWin = gtk.ScrolledWindow()
|
ScrollWin = gtk.ScrolledWindow()
|
||||||
LayoutTable = gtk.Table()
|
LayoutTable = gtk.Table()
|
||||||
EntryBox = gtk.Entry()
|
EntryBox = gtk.Entry()
|
||||||
Clipboard = gtk.Clipboard()
|
Clipboard = gtk.Clipboard()
|
||||||
ActiveHostsMenu = gtk.Menu()
|
|
||||||
|
|
||||||
### Methods ###
|
# Methods #
|
||||||
def reflowTable(self, cols=1, rows=1):
|
def reflowTable(self, cols=1, rows=1):
|
||||||
# empty table and re-size
|
# empty table and re-size
|
||||||
hosts = sorted(self.Terminals.keys(), reverse=True)
|
hosts = list(self.Terminals.keys())
|
||||||
|
hosts.reverse()
|
||||||
for host in hosts:
|
for host in hosts:
|
||||||
if self.Terminals[host].parent == self.LayoutTable:
|
if self.Terminals[host].get_parent() == self.LayoutTable:
|
||||||
self.LayoutTable.remove(self.Terminals[host])
|
self.LayoutTable.remove(self.Terminals[host])
|
||||||
self.LayoutTable.resize(rows, cols)
|
self.LayoutTable.resize(rows, cols)
|
||||||
# layout terminals
|
# layout terminals
|
||||||
|
@ -149,7 +222,7 @@ class CruSSH:
|
||||||
gtk.main_quit()
|
gtk.main_quit()
|
||||||
# main_quit desn't happen immediately
|
# main_quit desn't happen immediately
|
||||||
return False
|
return False
|
||||||
size = self.MainWin.allocation
|
size = self.MainWin.get_size()
|
||||||
cols = int(math.floor((size.width + self.LayoutTable.props.column_spacing) / float(self.TermMinWidth)))
|
cols = int(math.floor((size.width + self.LayoutTable.props.column_spacing) / float(self.TermMinWidth)))
|
||||||
if cols < 1 or num_terms == 1:
|
if cols < 1 or num_terms == 1:
|
||||||
cols = 1
|
cols = 1
|
||||||
|
@ -158,35 +231,76 @@ class CruSSH:
|
||||||
rows = int(math.ceil(num_terms / float(cols)))
|
rows = int(math.ceil(num_terms / float(cols)))
|
||||||
if rows < 1:
|
if rows < 1:
|
||||||
rows = 1
|
rows = 1
|
||||||
|
# ensure we evenly distribute terminals per row.
|
||||||
|
cols = int(math.ceil(num_terms / float(rows)))
|
||||||
|
|
||||||
if (self.LayoutTable.props.n_columns != cols) or (self.LayoutTable.props.n_rows != rows) or force:
|
if (self.LayoutTable.props.n_columns != cols) or (self.LayoutTable.props.n_rows != rows) or force:
|
||||||
self.reflowTable(cols, rows)
|
self.reflowTable(cols, rows)
|
||||||
self.MainWin.show_all()
|
self.MainWin.show_all()
|
||||||
|
|
||||||
|
def addHost(self, host):
|
||||||
|
def handle_copy_paste(widget, event):
|
||||||
|
self.EntryBox.props.buffer.delete_text(0, -1)
|
||||||
|
|
||||||
|
# check for paste key shortcut (ctl-shift-v)
|
||||||
|
if (event.type == gdk.EventType.KEY_PRESS) \
|
||||||
|
and (event.state & gdk.ModifierType.CONTROL_MASK == gdk.ModifierType.CONTROL_MASK) \
|
||||||
|
and (event.state & gdk.ModifierType.SHIFT_MASK == gdk.ModifierType.SHIFT_MASK) \
|
||||||
|
and (event.keyval == gdk.keyval_from_name('V')):
|
||||||
|
widget.paste_clipboard()
|
||||||
|
return True
|
||||||
|
elif (event.type == gdk.EventType.KEY_PRESS) \
|
||||||
|
and (event.state & gdk.ModifierType.CONTROL_MASK == gdk.ModifierType.CONTROL_MASK) \
|
||||||
|
and (event.state & gdk.ModifierType.SHIFT_MASK == gdk.ModifierType.SHIFT_MASK) \
|
||||||
|
and (event.keyval == gdk.keyval_from_name('C')):
|
||||||
|
widget.copy_clipboard()
|
||||||
|
return True
|
||||||
|
terminal = vte.Terminal()
|
||||||
|
terminal.spawn_sync(
|
||||||
|
vte.PtyFlags.DEFAULT,
|
||||||
|
None,
|
||||||
|
[self.ssh_cmd] + self.ssh_args + [host],
|
||||||
|
[],
|
||||||
|
glib.SpawnFlags.DO_NOT_REAP_CHILD,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
# track whether we mirror output to this terminal
|
||||||
|
terminal.copy_input = True
|
||||||
|
# attach copy/paste handler
|
||||||
|
terminal.connect("key_press_event", handle_copy_paste)
|
||||||
|
self.Terminals[host] = terminal
|
||||||
|
# hook terminals so they reflow layout on exit
|
||||||
|
self.Terminals[host].connect("child-exited", self.removeTerminal)
|
||||||
|
|
||||||
def configTerminals(self):
|
def configTerminals(self):
|
||||||
for host in self.Terminals:
|
for host in self.Terminals:
|
||||||
terminal = self.Terminals[host]
|
terminal = self.Terminals[host]
|
||||||
# -1 == infinite scrollback
|
# -1 == infinite scrollback
|
||||||
terminal.set_scrollback_lines(-1)
|
terminal.set_scrollback_lines(-1)
|
||||||
terminal.set_size(self.Config["min-width"], self.Config["min-height"])
|
terminal.set_size(self.Config["min-width"], self.Config["min-height"])
|
||||||
terminal.set_font_from_string(self.Config["font"])
|
terminal.set_font(FontDescription(self.Config["font"]))
|
||||||
self.TermMinWidth = (terminal.get_char_width() * self.Config["min-width"]) + terminal.get_padding()[0]
|
self.TermMinWidth = terminal.get_char_width() * self.Config["min-width"]
|
||||||
self.TermMinHeight = (terminal.get_char_height() * self.Config["min-height"]) + terminal.get_padding()[1]
|
self.TermMinHeight = terminal.get_char_height() * self.Config["min-height"]
|
||||||
|
|
||||||
def removeTerminal(self, terminal):
|
def removeTerminal(self, terminal, status):
|
||||||
# brute force search since we don't actually know the hostname from the
|
to_del = []
|
||||||
# terminal object. this is an infrequent operation, so it should be fine.
|
|
||||||
for host in self.Terminals.keys():
|
for host in self.Terminals.keys():
|
||||||
if terminal == self.Terminals[host]:
|
if terminal == self.Terminals[host]:
|
||||||
self.LayoutTable.remove(self.Terminals[host])
|
self.LayoutTable.remove(self.Terminals[host])
|
||||||
|
print("Disconnected from " + host)
|
||||||
|
to_del.append(host)
|
||||||
|
for host in to_del:
|
||||||
del self.Terminals[host]
|
del self.Terminals[host]
|
||||||
|
|
||||||
self.reflow(force=True)
|
self.reflow(force=True)
|
||||||
|
|
||||||
def initGUI(self):
|
def initGUI(self):
|
||||||
theme = gtk.icon_theme_get_default()
|
theme = gtk.IconTheme.get_default()
|
||||||
if theme.has_icon("terminal"):
|
if theme.has_icon("terminal"):
|
||||||
icon = theme.lookup_icon("terminal", 128, flags=gtk.ICON_LOOKUP_USE_BUILTIN)
|
icon = theme.lookup_icon("terminal", 128, flags=gtk.IconLookupFlags.USE_BUILTIN)
|
||||||
if icon is not None:
|
if icon is not None:
|
||||||
gtk.window_set_default_icon(icon.load_icon())
|
self.MainWin.set_icon(icon.load_icon())
|
||||||
self.MainWin.set_title("crussh: " + ' '.join(self.Terminals.keys()))
|
self.MainWin.set_title("crussh: " + ' '.join(self.Terminals.keys()))
|
||||||
self.MainWin.set_role(role="crussh_main_win")
|
self.MainWin.set_role(role="crussh_main_win")
|
||||||
self.MainWin.connect("delete-event", lambda window, event: gtk.main_quit())
|
self.MainWin.connect("delete-event", lambda window, event: gtk.main_quit())
|
||||||
|
@ -194,11 +308,26 @@ class CruSSH:
|
||||||
self.MainWin.add(MainVBox)
|
self.MainWin.add(MainVBox)
|
||||||
|
|
||||||
MainMenuBar = gtk.MenuBar()
|
MainMenuBar = gtk.MenuBar()
|
||||||
MainVBox.pack_start(MainMenuBar, fill=True, expand=False)
|
MainVBox.pack_start(MainMenuBar, False, False, 0)
|
||||||
|
|
||||||
|
def add_host_handler(self, base):
|
||||||
|
diag = EntryDialog(self.get_parent().get_parent(),
|
||||||
|
gtk.DialogFlags.MODAL | gtk.DialogFlags.DESTROY_WITH_PARENT,
|
||||||
|
gtk.MessageType.QUESTION, gtk.ButtonsType.OK_CANCEL,
|
||||||
|
message_format="Hostname to add:")
|
||||||
|
host = diag.run()
|
||||||
|
if host is not None and len(host) > 0:
|
||||||
|
base.addHost(host)
|
||||||
|
diag.destroy()
|
||||||
|
base.reflow(force=True)
|
||||||
|
|
||||||
FileItem = gtk.MenuItem(label="File")
|
FileItem = gtk.MenuItem(label="File")
|
||||||
FileMenu = gtk.Menu()
|
FileMenu = gtk.Menu()
|
||||||
FileItem.set_submenu(FileMenu)
|
FileItem.set_submenu(FileMenu)
|
||||||
|
AddHostItem = gtk.MenuItem(label="Add Host")
|
||||||
|
AddHostItem.connect("activate", add_host_handler, self)
|
||||||
|
FileMenu.append(AddHostItem)
|
||||||
|
FileMenu.append(gtk.SeparatorMenuItem())
|
||||||
QuitItem = gtk.MenuItem(label="Quit")
|
QuitItem = gtk.MenuItem(label="Quit")
|
||||||
QuitItem.connect("activate", lambda discard: gtk.main_quit())
|
QuitItem.connect("activate", lambda discard: gtk.main_quit())
|
||||||
FileMenu.append(QuitItem)
|
FileMenu.append(QuitItem)
|
||||||
|
@ -208,16 +337,8 @@ class CruSSH:
|
||||||
EditMenu = gtk.Menu()
|
EditMenu = gtk.Menu()
|
||||||
EditItem.set_submenu(EditMenu)
|
EditItem.set_submenu(EditMenu)
|
||||||
|
|
||||||
def toggle_func(checkitem, host):
|
|
||||||
self.Terminals[host].copy_input = checkitem.get_active()
|
|
||||||
ActiveHostsItem = gtk.MenuItem(label="Active Hosts")
|
ActiveHostsItem = gtk.MenuItem(label="Active Hosts")
|
||||||
ActiveHostsItem.set_submenu(self.ActiveHostsMenu)
|
ActiveHostsItem.connect("activate", lambda discard: HostsMask(self.Terminals))
|
||||||
hosts = sorted(self.Terminals.keys(), reverse=True)
|
|
||||||
for host in hosts:
|
|
||||||
hostitem = gtk.CheckMenuItem(label=host)
|
|
||||||
hostitem.set_active(True)
|
|
||||||
hostitem.connect("toggled", toggle_func, host)
|
|
||||||
self.ActiveHostsMenu.append(hostitem)
|
|
||||||
|
|
||||||
EditMenu.append(ActiveHostsItem)
|
EditMenu.append(ActiveHostsItem)
|
||||||
PrefsItem = gtk.MenuItem(label="Preferences")
|
PrefsItem = gtk.MenuItem(label="Preferences")
|
||||||
|
@ -226,26 +347,23 @@ class CruSSH:
|
||||||
self.Config = new_config
|
self.Config = new_config
|
||||||
self.reflow(force=True)
|
self.reflow(force=True)
|
||||||
# save to file last, so it doesn't hold up other GUI actions
|
# save to file last, so it doesn't hold up other GUI actions
|
||||||
conf_json = json.dumps(self.Config, sort_keys=True, indent=4)
|
conf_yaml = yaml.dump(self.Config, sort_keys=True, indent=4)
|
||||||
try:
|
conf_file = open(os.path.expanduser("~/.config/crussh.yml"), 'w')
|
||||||
conf_file = open(os.path.expanduser("~/.crusshrc"), 'w')
|
conf_file.write(conf_yaml)
|
||||||
conf_file.write(conf_json)
|
|
||||||
conf_file.close()
|
conf_file.close()
|
||||||
except:
|
|
||||||
pass
|
|
||||||
PrefsItem.connect("activate", lambda discard: CruSSHConf(self.Config, save_func))
|
PrefsItem.connect("activate", lambda discard: CruSSHConf(self.Config, save_func))
|
||||||
EditMenu.append(PrefsItem)
|
EditMenu.append(PrefsItem)
|
||||||
MainMenuBar.append(EditItem)
|
MainMenuBar.append(EditItem)
|
||||||
|
|
||||||
self.ScrollWin.props.hscrollbar_policy = gtk.POLICY_NEVER
|
self.ScrollWin.props.hscrollbar_policy = gtk.PolicyType.NEVER
|
||||||
self.ScrollWin.props.vscrollbar_policy = gtk.POLICY_ALWAYS
|
self.ScrollWin.props.vscrollbar_policy = gtk.PolicyType.ALWAYS
|
||||||
self.ScrollWin.props.shadow_type = gtk.SHADOW_ETCHED_IN
|
self.ScrollWin.props.shadow_type = gtk.ShadowType.ETCHED_IN
|
||||||
MainVBox.pack_start(self.ScrollWin)
|
MainVBox.pack_start(self.ScrollWin, True, True, 0)
|
||||||
|
|
||||||
self.LayoutTable.set_homogeneous(True)
|
self.LayoutTable.set_homogeneous(True)
|
||||||
self.LayoutTable.set_row_spacings(1)
|
self.LayoutTable.set_row_spacings(1)
|
||||||
self.LayoutTable.set_col_spacings(1)
|
self.LayoutTable.set_col_spacings(1)
|
||||||
self.ScrollWin.add_with_viewport(self.LayoutTable)
|
self.ScrollWin.add(self.LayoutTable)
|
||||||
self.ScrollWin.set_size_request(self.TermMinWidth, self.TermMinHeight)
|
self.ScrollWin.set_size_request(self.TermMinWidth, self.TermMinHeight)
|
||||||
|
|
||||||
# don't display chars while typing.
|
# don't display chars while typing.
|
||||||
|
@ -256,17 +374,18 @@ class CruSSH:
|
||||||
def feed_paste(widget):
|
def feed_paste(widget):
|
||||||
for host in self.Terminals:
|
for host in self.Terminals:
|
||||||
if self.Terminals[host].copy_input:
|
if self.Terminals[host].copy_input:
|
||||||
self.Terminals[host].feed_child(self.Clipboard.wait_for_text())
|
self.Terminals[host].paste_clipboard()
|
||||||
self.EntryBox.props.buffer.delete_text(0, -1)
|
self.EntryBox.props.buffer.delete_text(0, -1)
|
||||||
|
|
||||||
# forward key events to all terminals with copy_input set
|
# forward key events to all terminals with copy_input set
|
||||||
def feed_input(widget, event):
|
def feed_input(widget, event):
|
||||||
self.EntryBox.props.buffer.delete_text(0, -1)
|
self.EntryBox.props.buffer.delete_text(0, -1)
|
||||||
# check for paste key shortcut (ctl-shift-v)
|
# check for paste key shortcut (ctl-shift-v)
|
||||||
if (event.type == gtk.gdk.KEY_PRESS) \
|
|
||||||
and (event.state & gtk.gdk.CONTROL_MASK == gtk.gdk.CONTROL_MASK) \
|
if (event.type == gdk.EventType.KEY_PRESS) \
|
||||||
and (event.state & gtk.gdk.SHIFT_MASK == gtk.gdk.SHIFT_MASK) \
|
and (event.state & gdk.ModifierType.CONTROL_MASK == gdk.ModifierType.CONTROL_MASK) \
|
||||||
and (event.keyval == 86):
|
and (event.state & gdk.ModifierType.SHIFT_MASK == gdk.ModifierType.SHIFT_MASK) \
|
||||||
|
and (event.get_keyval == gdk.keyval_from_name('V')):
|
||||||
feed_paste(widget)
|
feed_paste(widget)
|
||||||
else:
|
else:
|
||||||
# propagate to every terminal
|
# propagate to every terminal
|
||||||
|
@ -286,7 +405,7 @@ class CruSSH:
|
||||||
self.EntryBox.connect("key_release_event", feed_input)
|
self.EntryBox.connect("key_release_event", feed_input)
|
||||||
self.EntryBox.connect("paste_clipboard", feed_paste)
|
self.EntryBox.connect("paste_clipboard", feed_paste)
|
||||||
self.EntryBox.connect("button_press_event", click_handler)
|
self.EntryBox.connect("button_press_event", click_handler)
|
||||||
MainVBox.pack_start(self.EntryBox, False, False)
|
MainVBox.pack_start(self.EntryBox, False, False, 0)
|
||||||
|
|
||||||
# reflow layout on size change.
|
# reflow layout on size change.
|
||||||
self.MainWin.connect("size-allocate", lambda widget, allocation: self.reflow())
|
self.MainWin.connect("size-allocate", lambda widget, allocation: self.reflow())
|
||||||
|
@ -294,62 +413,66 @@ class CruSSH:
|
||||||
# give EntryBox default focus on init
|
# give EntryBox default focus on init
|
||||||
self.EntryBox.props.has_focus = True
|
self.EntryBox.props.has_focus = True
|
||||||
|
|
||||||
def __init__(self, hosts, ssh_cmd="/usr/bin/ssh", ssh_args=None):
|
def __init__(self, hosts, ssh_cmd="/usr/bin/ssh", ssh_args=[]):
|
||||||
|
self.ssh_cmd = ssh_cmd
|
||||||
|
self.ssh_args = ssh_args
|
||||||
# load existing config file, if present
|
# load existing config file, if present
|
||||||
try:
|
try:
|
||||||
# merge dicts to allow upgrade from old configs
|
# merge dicts to allow upgrade from old configs
|
||||||
new_config = json.load(open(os.path.expanduser('~/.crusshrc')))
|
new_config = yaml.safe_load(open(os.path.expanduser('~/.config/crussh.yml')))
|
||||||
|
if new_config is not None:
|
||||||
self.Config.update(new_config)
|
self.Config.update(new_config)
|
||||||
except:
|
except (FileNotFoundError, yaml.scanner.ScannerError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# init all terminals
|
# init all terminals
|
||||||
|
if isinstance(hosts, list):
|
||||||
for host in hosts:
|
for host in hosts:
|
||||||
terminal = vte.Terminal()
|
self.addHost(host)
|
||||||
# TODO: disable only this terminal widget on child exit
|
else:
|
||||||
# v.connect("child-exited", lambda term: gtk.main_quit())
|
self.addHost(str(hosts))
|
||||||
cmd_str = ssh_cmd
|
|
||||||
if ssh_args is not None:
|
|
||||||
cmd_str += " " + ssh_args
|
|
||||||
cmd_str += " " + host
|
|
||||||
cmd = cmd_str.split(' ')
|
|
||||||
terminal.fork_command(command=cmd[0], argv=cmd)
|
|
||||||
# track whether we mirror output to this terminal
|
|
||||||
terminal.copy_input = True
|
|
||||||
self.Terminals[host] = terminal
|
|
||||||
|
|
||||||
# hook terminals so they reflow layout on exit
|
|
||||||
self.Terminals[host].connect("child-exited", self.removeTerminal)
|
|
||||||
# configure all terminals
|
# configure all terminals
|
||||||
self.configTerminals()
|
self.configTerminals()
|
||||||
# reflow after reconfig for font size changes
|
# reflow after reconfig for font size changes
|
||||||
self.initGUI()
|
self.initGUI()
|
||||||
|
if self.Config["start-maximized"]:
|
||||||
|
self.MainWin.maximize()
|
||||||
self.reflow(force=True)
|
self.reflow(force=True)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import argparse
|
import argparse
|
||||||
|
|
||||||
### Parse CLI Args ###
|
# Parse CLI Args #
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Connect to multiple hosts in parallel.",
|
description="Connect to multiple hosts in parallel.",
|
||||||
usage="%(prog)s [OPTIONS] [--] HOST [HOST ...]",
|
usage="%(prog)s [OPTIONS] [--] HOST [HOST ...]",
|
||||||
epilog="* NOTE: You can pass options to ssh if you add '--' before your list of hosts")
|
epilog="* NOTE: options before '--' will be passed directly to the SSH client.")
|
||||||
parser.add_argument("--ssh", dest='ssh', default="/usr/bin/ssh",
|
parser.add_argument("--ssh", "-s", dest='ssh', default="/usr/bin/ssh",
|
||||||
help="specify the SSH executable to use (default: %(default)s)")
|
help="specify the SSH executable to use (default: %(default)s)")
|
||||||
|
parser.add_argument("--hosts-file", "-f", dest='hosts_file', default="",
|
||||||
|
help="A file containing a list of hosts to connect to.")
|
||||||
(args, hosts) = parser.parse_known_args()
|
(args, hosts) = parser.parse_known_args()
|
||||||
|
|
||||||
|
# load hosts from file, if available
|
||||||
|
if args.hosts_file != "":
|
||||||
|
# hostnames are assumed to be whitespace separated
|
||||||
|
extra_hosts = open(args.hosts_file, 'r').read().split()
|
||||||
|
hosts.extend(extra_hosts)
|
||||||
|
|
||||||
if len(hosts) == 0:
|
if len(hosts) == 0:
|
||||||
parser.print_usage()
|
parser.print_usage()
|
||||||
parser.exit(2)
|
parser.exit(2)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
offset = hosts.index("--")
|
offset = hosts.index("--")
|
||||||
except:
|
ssh_args = hosts[0:offset]
|
||||||
ssh_args = None
|
except ValueError:
|
||||||
else:
|
offset = -1
|
||||||
ssh_args = " ".join(hosts[0:offset])
|
ssh_args = []
|
||||||
|
|
||||||
hosts = hosts[offset + 1:]
|
hosts = hosts[offset + 1:]
|
||||||
|
|
||||||
### Start Execution ###
|
# Start Execution #
|
||||||
crussh = CruSSH(hosts, args.ssh, ssh_args)
|
crussh = CruSSH(hosts, args.ssh, ssh_args)
|
||||||
gtk.main()
|
gtk.main()
|
||||||
|
|
Loading…
Add table
Reference in a new issue