Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec5ca37356 | ||
|
|
5c974f31ce | ||
|
|
25115f8747 | ||
|
|
c3a4d06310 | ||
|
|
b4eedc77bb | ||
|
|
93123c5c60 | ||
|
|
3714205dab | ||
|
|
4818523b29 | ||
|
|
6f52873b69 |
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mrterm",
|
||||
"version": "0.19.0",
|
||||
"version": "0.21.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mrterm",
|
||||
"version": "0.19.0",
|
||||
"version": "0.21.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "mrterm",
|
||||
"productName": "MrTerm",
|
||||
"version": "0.19.0",
|
||||
"version": "0.21.1",
|
||||
"description": "Moderner SSH-, SFTP- und RDP-Client",
|
||||
"main": "src/main/main.js",
|
||||
"author": "MrBlake",
|
||||
|
||||
+5
-1
@@ -151,6 +151,11 @@
|
||||
'Send Enter automatically': 'Enter automatisch senden',
|
||||
'Command is missing.': 'Befehl fehlt.',
|
||||
'No open terminal': 'Kein offenes Terminal',
|
||||
'Upload files …': 'Dateien hochladen …',
|
||||
'Root privileges are required. Log in as root or enter the password of a user with sudo rights.': 'Dafür sind root-Rechte nötig. Melde dich als root an oder gib das Passwort eines Benutzers mit sudo-Rechten ein.',
|
||||
'sudo password': 'sudo-Passwort',
|
||||
'sudo password for {user}': 'sudo-Passwort für {user}',
|
||||
'Wrong password. sudo password for {user}': 'Falsches Passwort. sudo-Passwort für {user}',
|
||||
// Support-Assistent
|
||||
'Delete chat': 'Chat löschen',
|
||||
'Delete chat?': 'Chat löschen?',
|
||||
@@ -578,7 +583,6 @@
|
||||
'Invalid value for {field}: {value}': 'Ungültiger Wert für {field}: {value}',
|
||||
'Firewall session not found': 'Firewall-Sitzung nicht gefunden',
|
||||
'Command failed with code {code}': 'Befehl fehlgeschlagen mit Code {code}',
|
||||
'Root privileges are required. Log in as root or save the password of a user with sudo rights on the host.': 'Dafür sind root-Rechte nötig. Melde dich als root an oder hinterlege am Host das Passwort eines Benutzers mit sudo-Rechten.',
|
||||
'This host uses firewalld, which is not supported yet.': 'Dieser Host nutzt firewalld, das noch nicht unterstützt wird.',
|
||||
'Neither UFW nor iptables was found on this host.': 'Auf diesem Host wurde weder UFW noch iptables gefunden.',
|
||||
'Port ranges and lists need a protocol (TCP or UDP).': 'Portbereiche und -listen brauchen ein Protokoll (TCP oder UDP).',
|
||||
|
||||
+13
-6
@@ -2,6 +2,7 @@
|
||||
// Kein Zugriff auf den Docker-Socket nötig: MrTerm führt `docker ps`, `docker start` … aus.
|
||||
// Fehlt die Berechtigung (Benutzer nicht in der Gruppe "docker"), wird sudo mit dem gespeicherten Host-Passwort versucht.
|
||||
const i18n = require('../i18n');
|
||||
const { acquireSudo } = require('./sudo');
|
||||
|
||||
const SAFE_ID = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
|
||||
const ACTIONS = { start: 'start', stop: 'stop', restart: 'restart', remove: 'rm -f' };
|
||||
@@ -37,12 +38,18 @@ class DockerManager {
|
||||
|
||||
async open(id, host, onClose) {
|
||||
const { conn, jumps } = await this.ssh.connect(host, id);
|
||||
this.sessions.set(id, { conn, jumps, host, runtime: null, sudo: false });
|
||||
this.sessions.set(id, { id, conn, jumps, host, runtime: null, sudo: false });
|
||||
conn.on('close', () => { if (this.sessions.has(id)) { this.close(id); onClose(); } });
|
||||
conn.on('error', () => {});
|
||||
return this.list(id);
|
||||
}
|
||||
|
||||
// sudo-Passwort einmal pro Sitzung ermitteln (ohne Passwort, gespeichertes Host-Passwort oder Nachfrage)
|
||||
async sudo(id, s) {
|
||||
if (s.sudoPw === undefined) s.sudoPw = await acquireSudo(this.ssh, id, s.host, (c, stdin) => execOn(s.conn, c, stdin));
|
||||
return s.sudoPw != null;
|
||||
}
|
||||
|
||||
get(id) {
|
||||
const s = this.sessions.get(id);
|
||||
if (!s) throw new Error(i18n.t('Docker session not found'));
|
||||
@@ -51,7 +58,7 @@ class DockerManager {
|
||||
|
||||
run(s, args) {
|
||||
const sudo = s.sudo ? "sudo -S -p '' " : '';
|
||||
return execOn(s.conn, `${sudo}${s.runtime} ${args}`, s.sudo ? `${s.host.password || ''}\n` : '');
|
||||
return execOn(s.conn, `${sudo}${s.runtime} ${args}`, s.sudo ? `${s.sudoPw || ''}\n` : '');
|
||||
}
|
||||
|
||||
async list(id) {
|
||||
@@ -62,7 +69,7 @@ class DockerManager {
|
||||
s.runtime = r.out.trim();
|
||||
}
|
||||
let r = await this.run(s, `ps -a --format ${LIST_FMT}`);
|
||||
if (r.code && denied(r.err) && !s.sudo) {
|
||||
if (r.code && denied(r.err) && !s.sudo && await this.sudo(id, s)) {
|
||||
s.sudo = true;
|
||||
r = await this.run(s, `ps -a --format ${LIST_FMT}`);
|
||||
if (r.code) s.sudo = false;
|
||||
@@ -124,8 +131,8 @@ class DockerManager {
|
||||
// Shell-Skript ausführen, bei fehlender Berechtigung per sudo (nur mit gespeichertem Passwort)
|
||||
async sh(s, script) {
|
||||
let r = await execOn(s.conn, `sh -c ${q(script)}`);
|
||||
if (r.code && /permission denied|operation not permitted/i.test(r.err) && s.host.password) {
|
||||
r = await execOn(s.conn, `sudo -S -p '' sh -c ${q(script)}`, `${s.host.password}\n`);
|
||||
if (r.code && /permission denied|operation not permitted/i.test(r.err) && await this.sudo(s.id, s)) {
|
||||
r = await execOn(s.conn, `sudo -S -p '' sh -c ${q(script)}`, `${s.sudoPw}\n`);
|
||||
}
|
||||
return r;
|
||||
}
|
||||
@@ -158,7 +165,7 @@ class DockerManager {
|
||||
|
||||
runCompose(s, compose, args) {
|
||||
const sudo = s.sudo ? "sudo -S -p '' " : '';
|
||||
return execOn(s.conn, `${sudo}${compose} ${args}`, s.sudo ? `${s.host.password || ''}\n` : '');
|
||||
return execOn(s.conn, `${sudo}${compose} ${args}`, s.sudo ? `${s.sudoPw || ''}\n` : '');
|
||||
}
|
||||
|
||||
async readStack(id, file) {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// Alle Werte aus der Oberfläche werden streng geprüft, bevor sie in einen Shell-Befehl gelangen.
|
||||
const i18n = require('../i18n');
|
||||
const { execOn } = require('./docker');
|
||||
const { acquireSudo } = require('./sudo');
|
||||
|
||||
const lastLine = (s) => String(s || '').split('\n').map((l) => l.trim()).filter(Boolean).slice(-1)[0] || '';
|
||||
const RX = {
|
||||
@@ -58,7 +59,7 @@ class FirewallManager {
|
||||
// Befehl mit root-Rechten ausführen
|
||||
run(s, cmd) {
|
||||
if (!s.sudo) return execOn(s.conn, `PATH=$PATH:/usr/sbin:/sbin ${cmd}`);
|
||||
return execOn(s.conn, `sudo -S -p '' ${cmd}`, `${s.host.password || ''}\n`);
|
||||
return execOn(s.conn, `sudo -S -p '' ${cmd}`, `${s.sudoPw || ''}\n`);
|
||||
}
|
||||
async runOk(s, cmd) {
|
||||
const r = await this.run(s, cmd);
|
||||
@@ -75,8 +76,8 @@ class FirewallManager {
|
||||
const uid = (await execOn(conn, 'id -u')).out.trim();
|
||||
if (uid !== '0') {
|
||||
s.sudo = true;
|
||||
const r = await execOn(conn, "sudo -S -p '' -v", `${host.password || ''}\n`);
|
||||
if (r.code) throw new Error(i18n.t('Root privileges are required. Log in as root or save the password of a user with sudo rights on the host.'));
|
||||
s.sudoPw = await acquireSudo(this.ssh, id, host, (c, stdin) => execOn(conn, c, stdin));
|
||||
if (s.sudoPw == null) throw new Error(i18n.t('Root privileges are required. Log in as root or enter the password of a user with sudo rights.'));
|
||||
}
|
||||
const t = await this.run(s, 'sh -c \'for c in ufw iptables ip6tables firewall-cmd; do command -v $c >/dev/null 2>&1 && echo $c; done; true\'');
|
||||
s.tools = t.out.split('\n').map((x) => x.trim()).filter(Boolean);
|
||||
|
||||
@@ -408,6 +408,11 @@ handle('sftp:open', async (sessionId, hostRef) => {
|
||||
});
|
||||
handle('sftp:list', (id, dir) => ssh.sftpList(id, dir));
|
||||
handle('sftp:op', (id, op, a, b) => ssh.sftpOp(id, op, a, b));
|
||||
// Dateien für den SFTP-Upload auswählen (Mehrfachauswahl)
|
||||
handle('sftp:pickUpload', async () => {
|
||||
const r = await dialog.showOpenDialog(win, { title: i18n.t('Upload'), properties: ['openFile', 'multiSelections', 'showHiddenFiles'] });
|
||||
return r.canceled ? [] : r.filePaths.map((p) => ({ path: p, name: path.basename(p) }));
|
||||
});
|
||||
handle('sftp:transfer', async (id, dir, localPath, remotePath, transferId) => {
|
||||
let last = 0;
|
||||
await ssh.transfer(id, dir, localPath, remotePath, (done, total) => {
|
||||
|
||||
+4
-3
@@ -4,6 +4,7 @@
|
||||
// nach 90 s selbst wieder her, falls MrTerm sich nicht erneut verbinden und die Änderung bestätigen kann.
|
||||
const i18n = require('../i18n');
|
||||
const { execOn } = require('./docker');
|
||||
const { acquireSudo } = require('./sudo');
|
||||
const nc = require('./netconf');
|
||||
|
||||
const STATE = '/var/lib/mrterm-net';
|
||||
@@ -127,7 +128,7 @@ class NetworkConfigManager {
|
||||
root(s, script, conn = s.conn) {
|
||||
const cmd = `sh -c "$(printf %s ${b64(script)} | base64 -d)"`;
|
||||
if (!s.sudo) return execOn(conn, cmd);
|
||||
return execOn(conn, `sudo -S -p '' ${cmd}`, `${s.host.password || ''}\n`);
|
||||
return execOn(conn, `sudo -S -p '' ${cmd}`, `${s.sudoPw || ''}\n`);
|
||||
}
|
||||
async rootOk(s, script) {
|
||||
const r = await this.root(s, script);
|
||||
@@ -142,8 +143,8 @@ class NetworkConfigManager {
|
||||
this.watch(id, s);
|
||||
if ((await execOn(conn, 'id -u')).out.trim() !== '0') {
|
||||
s.sudo = true;
|
||||
const r = await execOn(conn, "sudo -S -p '' -v", `${host.password || ''}\n`);
|
||||
if (r.code) throw new Error(i18n.t('Root privileges are required. Log in as root or save the password of a user with sudo rights on the host.'));
|
||||
s.sudoPw = await acquireSudo(this.ssh, id, host, (c, stdin) => execOn(conn, c, stdin));
|
||||
if (s.sudoPw == null) throw new Error(i18n.t('Root privileges are required. Log in as root or enter the password of a user with sudo rights.'));
|
||||
}
|
||||
return this.read(id);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// sudo für Firewall, Netzwerk und Docker: erst ohne Passwort (NOPASSWD), dann mit dem am Host gespeicherten Passwort,
|
||||
// sonst den Nutzer fragen (bis zu 3 Versuche). Das Passwort gilt nur für die offene Sitzung.
|
||||
// run(cmd, stdin) → Promise<{ code, out, err }>
|
||||
// → Passwort ('' bei NOPASSWD) oder null, wenn sudo nicht möglich ist bzw. abgebrochen wurde
|
||||
const i18n = require('../i18n');
|
||||
|
||||
async function acquireSudo(ssh, sessionId, host, run) {
|
||||
if ((await run('sudo -n true')).code === 0) return '';
|
||||
const works = async (pw) => (await run("sudo -S -p '' -v", `${pw}\n`)).code === 0;
|
||||
if (host.password && await works(host.password)) return host.password;
|
||||
const user = `${host.username || ''}@${host.address}`.replace(/^@/, '');
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const pw = await ssh.askSecret(sessionId, {
|
||||
title: i18n.t('sudo password'),
|
||||
prompt: i ? i18n.t('Wrong password. sudo password for {user}', { user }) : i18n.t('sudo password for {user}', { user }),
|
||||
echo: false,
|
||||
host: host.label || host.address,
|
||||
});
|
||||
if (pw == null) return null;
|
||||
if (await works(pw)) return pw;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = { acquireSudo };
|
||||
+12
-4
@@ -1887,7 +1887,8 @@ function initAi() {
|
||||
AI.chat = aiChats().find((c) => c.id === e.target.value) || null;
|
||||
aiRender();
|
||||
};
|
||||
p.addEventListener('click', async (e) => {
|
||||
// onclick statt addEventListener: initAi() läuft bei jedem reload(), sonst doppelte Handler (z. B. zwei Löschdialoge)
|
||||
p.onclick = async (e) => {
|
||||
const a = e.target.closest('[data-ai]')?.dataset.ai;
|
||||
if (a === 'new') { if (AI.busy) api.call('ai:stop', AI.chat?.id); AI.chat = null; aiRender(); ta.focus(); }
|
||||
if (a === 'del' && AI.chat && await confirmBox(T('Delete chat?'), `“${AI.chat.title}”`, T('Delete'))) {
|
||||
@@ -1907,7 +1908,7 @@ function initAi() {
|
||||
// Nur einfügen, nicht ausführen – der Nutzer prüft und bestätigt mit Enter
|
||||
if (b.dataset.cmd === 'paste' && t?.kind === 'ssh') { t.term.paste(code); t.term.focus(); }
|
||||
else { api.call('clipboard:write', code); toast(b.dataset.cmd === 'paste' ? T('Copied – no terminal tab is active') : T('Copied')); }
|
||||
});
|
||||
};
|
||||
if ($('#aiPanel').classList.contains('open')) aiRender();
|
||||
if (AI.init) return;
|
||||
AI.init = true;
|
||||
@@ -2041,7 +2042,7 @@ class FilePane {
|
||||
<input class="path" spellcheck="false"/>
|
||||
<button class="btn ghost sm" data-p="refresh" title="${T('Refresh')}">${ICONS.refresh}</button>
|
||||
<button class="btn ghost sm" data-p="mkdir" title="${T('New folder')}">${ICONS.newdir}</button>
|
||||
${WEB ? `<button class="btn ghost sm" data-p="upload" title="${T('Upload')}">${ICONS.upload}</button><button class="btn ghost sm" data-p="xfer" title="${T('Download')}">${ICONS.download}</button>` : `<button class="btn ghost sm" data-p="xfer" title="${side === 'local' ? T('Upload →') : T('← Download')}">${side === 'local' ? ICONS.arrowR : ICONS.arrowL}</button>`}</div>
|
||||
${WEB ? `<button class="btn ghost sm" data-p="upload" title="${T('Upload')}">${ICONS.upload}</button><button class="btn ghost sm" data-p="xfer" title="${T('Download')}">${ICONS.download}</button>` : `${side === 'remote' ? `<button class="btn ghost sm" data-p="upload" title="${T('Upload files …')}">${ICONS.upload}</button>` : ''}<button class="btn ghost sm" data-p="xfer" title="${side === 'local' ? T('Upload →') : T('← Download')}">${side === 'local' ? ICONS.arrowR : ICONS.arrowL}</button>`}</div>
|
||||
<div class="files"></div></div>`);
|
||||
this.el.addEventListener('click', (e) => {
|
||||
const p = e.target.closest('[data-p]')?.dataset.p;
|
||||
@@ -2068,7 +2069,14 @@ class FilePane {
|
||||
});
|
||||
}
|
||||
get other() { return this.side === 'local' ? this.sftp.remote : this.sftp.local; }
|
||||
pickUpload() {
|
||||
async pickUpload() {
|
||||
if (!WEB) {
|
||||
// Desktop: Dateiauswahl des Systems, Upload in den aktuellen Server-Ordner
|
||||
const files = await call('sftp:pickUpload');
|
||||
for (const f of files) await this.sftp.transfer('upload', f.path, await this.join(this.cwd, f.name), f.name);
|
||||
if (files.length) this.go(this.cwd);
|
||||
return;
|
||||
}
|
||||
const inp = h('<input type="file" multiple style="display:none"/>');
|
||||
inp.onchange = () => { this.uploadFiles([...inp.files]); inp.remove(); };
|
||||
document.body.append(inp);
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<svg viewBox="0 0 24 24"><path d="M4 5h16v14H4z M4 9h16" /></svg><span>Vault</span>
|
||||
</button>
|
||||
</div>
|
||||
<button id="newTabBtn" class="icon-btn" title="Quick Connect (Ctrl+Shift+K)" data-i18n-title="Quick Connect (Ctrl+Shift+K)">+</button>
|
||||
<button id="newTabBtn" class="icon-btn" title="Quick Connect (Ctrl+Shift+K)" data-i18n-title="Quick Connect (Ctrl+Shift+K)"><svg viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></svg></button>
|
||||
<div class="drag-fill"></div>
|
||||
<button id="aiBtn" class="icon-btn" data-ai-toggle title="Support assistant (Ollama)" data-i18n-title="Support assistant (Ollama)"><svg viewBox="0 0 24 24"><path d="M4 5h16v11H9l-5 4z"/><path d="M9 10h.01M12 10h.01M15 10h.01"/></svg></button>
|
||||
<button id="lockBtn" class="icon-btn lock-btn" style="display:none" title="Lock (Ctrl+Shift+L)" data-i18n-title="Lock (Ctrl+Shift+L)"><svg viewBox="0 0 24 24"><rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg></button>
|
||||
|
||||
@@ -24,23 +24,25 @@ svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width:
|
||||
.logo { width: 24px; height: 24px; border-radius: 6px; }
|
||||
.brand { gap: 8px !important; }
|
||||
.brand-term { color: #5ef0c4; margin-left: -8px; }
|
||||
#tabs { display: flex; gap: 4px; height: 100%; align-items: flex-end; overflow-x: auto; min-width: 0; -webkit-app-region: no-drag; }
|
||||
#tabs { display: flex; gap: 4px; height: 100%; align-items: center; overflow-x: auto; min-width: 0; -webkit-app-region: no-drag; }
|
||||
#tabs::-webkit-scrollbar { height: 0; }
|
||||
.tab { -webkit-app-region: no-drag; display: flex; align-items: center; gap: 8px; height: 32px; padding: 0 10px 0 12px; border: none; background: transparent; border-radius: 8px 8px 0 0; color: var(--muted); max-width: 220px; min-width: 110px; white-space: nowrap; position: relative; }
|
||||
.tab { -webkit-app-region: no-drag; display: flex; align-items: center; gap: 8px; height: 30px; padding: 0 8px 0 12px; border: none; background: transparent; border-radius: 8px; color: var(--muted); max-width: 220px; min-width: 110px; white-space: nowrap; position: relative; }
|
||||
.tab.home { min-width: 0; }
|
||||
.tab span { overflow: hidden; text-overflow: ellipsis; flex: 1; text-align: left; }
|
||||
.tab:hover { background: var(--hover); color: var(--text); }
|
||||
.tab.active { background: var(--bg); color: var(--text); }
|
||||
.tab.active { background: var(--bg); color: var(--text); box-shadow: 0 0 0 1px var(--chrome-line); }
|
||||
.tab .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--faint); flex: none; }
|
||||
.tab .dot.on { background: var(--green); box-shadow: 0 0 6px var(--green); }
|
||||
.tab .dot.err { background: var(--red); }
|
||||
.tab .dot.wait { background: var(--orange); animation: pulse 1s infinite; }
|
||||
.tab .x { opacity: 0; border: none; background: none; padding: 2px; border-radius: 4px; color: var(--muted); display: flex; }
|
||||
.tab .x { opacity: 0; border: none; background: none; padding: 3px; border-radius: 4px; color: var(--muted); display: flex; }
|
||||
.tab:hover .x, .tab.active .x { opacity: 1; }
|
||||
.tab .x:hover { background: rgb(var(--tint) / 0.094); color: var(--text); }
|
||||
.tab .x svg { width: 12px; height: 12px; }
|
||||
@keyframes pulse { 50% { opacity: .35; } }
|
||||
.icon-btn { -webkit-app-region: no-drag; border: none; background: transparent; color: var(--muted); width: 30px; height: 30px; border-radius: 8px; font-size: 18px; margin-left: 4px; flex: none; }
|
||||
.icon-btn { -webkit-app-region: no-drag; border: none; background: transparent; color: var(--muted); width: 30px; height: 30px; border-radius: 8px; margin-left: 4px; flex: none; display: flex; align-items: center; justify-content: center; padding: 0; }
|
||||
/* Alle Titelleisten-Symbole gleich groß und mittig (vorher: „+“ als Text mit eigener Grundlinie) */
|
||||
.icon-btn svg { width: 16px; height: 16px; }
|
||||
.icon-btn:hover { background: var(--chrome-line); color: var(--text); }
|
||||
.drag-fill { flex: 1; min-width: 40px; height: 100%; }
|
||||
.win-ctrls { display: flex; height: 100%; -webkit-app-region: no-drag; }
|
||||
@@ -317,7 +319,7 @@ kbd { background: var(--card); border: 1px solid var(--border); border-bottom-wi
|
||||
#sidebar.nav-collapsed .nav-menu .nav span { display: none; }
|
||||
|
||||
/* App-Sperre */
|
||||
.lock-btn svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 2; }
|
||||
.lock-btn svg { fill: none; stroke: currentColor; stroke-width: 2; }
|
||||
.lock-screen { position: fixed; inset: 0; top: var(--titlebar); z-index: 200; background: var(--bg); display: flex; align-items: center; justify-content: center; animation: fade .15s; }
|
||||
.lock-box { width: 340px; display: flex; flex-direction: column; align-items: center; gap: 14px; text-align: center; }
|
||||
.lock-box h2 { margin: 0 0 6px; font-size: 18px; }
|
||||
@@ -361,7 +363,7 @@ kbd { background: var(--card); border: 1px solid var(--border); border-bottom-wi
|
||||
|
||||
/* Firewall */
|
||||
.seg.sm button { padding: 4px 10px; font-size: 12px; }
|
||||
.session.docker .sbar .seg { margin: 0 6px 0 0; flex: none; }
|
||||
.session .sbar .seg { margin: 0 6px 0 0; flex: none; }
|
||||
.docker-table .dot.none { background: transparent; border: 1px solid var(--faint); }
|
||||
.docker-table .badge, .tpl-it .badge { font-size: 10px; font-weight: 600; padding: 1px 6px; border-radius: 6px; background: var(--icon-bg); color: var(--muted); margin-left: 4px; vertical-align: middle; }
|
||||
.stack-bar { display: flex; align-items: center; gap: 6px; padding: 8px 10px; border-bottom: 1px solid var(--border); flex-wrap: wrap; position: sticky; top: 0; background: var(--bg); z-index: 2; }
|
||||
|
||||
Reference in New Issue
Block a user