feat: tab notifications, session persistence, Docker panel, drag reorder sidebar
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 2m53s
All checks were successful
Build & Sign Wraith / Build Windows + Sign (push) Successful in 2m53s
Tab activity notifications: - Background tabs pulse blue when new output arrives - Clears when you switch to the tab - useTerminal marks activity on every data event Session persistence: - Workspace saved to DB on window close (connection IDs + positions) - Restored on launch — auto-reconnects saved sessions in order - workspace_commands: save_workspace, load_workspace Docker Manager (Tools → Docker Manager): - Containers tab: list all, start/stop/restart/remove/logs - Images tab: list all, remove - Volumes tab: list all, remove - One-click Builder Prune and System Prune buttons - All operations via SSH exec channels — no Docker socket exposure Sidebar drag-and-drop: - Drag groups to reorder - Drag connections between groups - Drag connections within a group to reorder - Blue border indicator on drop targets Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e6766062b1
commit
1b74527a62
115
src-tauri/src/commands/docker_commands.rs
Normal file
115
src-tauri/src/commands/docker_commands.rs
Normal file
@ -0,0 +1,115 @@
|
||||
//! Tauri commands for Docker management via SSH exec channels.
|
||||
|
||||
use tauri::State;
|
||||
use serde::Serialize;
|
||||
use crate::AppState;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DockerContainer {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub image: String,
|
||||
pub status: String,
|
||||
pub ports: String,
|
||||
pub created: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DockerImage {
|
||||
pub id: String,
|
||||
pub repository: String,
|
||||
pub tag: String,
|
||||
pub size: String,
|
||||
pub created: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DockerVolume {
|
||||
pub name: String,
|
||||
pub driver: String,
|
||||
pub mountpoint: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn docker_list_containers(session_id: String, all: Option<bool>, state: State<'_, AppState>) -> Result<Vec<DockerContainer>, String> {
|
||||
let session = state.ssh.get_session(&session_id).ok_or("Session not found")?;
|
||||
let flag = if all.unwrap_or(true) { "-a" } else { "" };
|
||||
let output = exec(&session.handle, &format!("docker ps {} --format '{{{{.ID}}}}|{{{{.Names}}}}|{{{{.Image}}}}|{{{{.Status}}}}|{{{{.Ports}}}}|{{{{.CreatedAt}}}}' 2>&1", flag)).await?;
|
||||
Ok(output.lines().filter(|l| !l.is_empty() && !l.starts_with("CONTAINER")).map(|line| {
|
||||
let p: Vec<&str> = line.splitn(6, '|').collect();
|
||||
DockerContainer {
|
||||
id: p.first().unwrap_or(&"").to_string(),
|
||||
name: p.get(1).unwrap_or(&"").to_string(),
|
||||
image: p.get(2).unwrap_or(&"").to_string(),
|
||||
status: p.get(3).unwrap_or(&"").to_string(),
|
||||
ports: p.get(4).unwrap_or(&"").to_string(),
|
||||
created: p.get(5).unwrap_or(&"").to_string(),
|
||||
}
|
||||
}).collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn docker_list_images(session_id: String, state: State<'_, AppState>) -> Result<Vec<DockerImage>, String> {
|
||||
let session = state.ssh.get_session(&session_id).ok_or("Session not found")?;
|
||||
let output = exec(&session.handle, "docker images --format '{{.ID}}|{{.Repository}}|{{.Tag}}|{{.Size}}|{{.CreatedAt}}' 2>&1").await?;
|
||||
Ok(output.lines().filter(|l| !l.is_empty()).map(|line| {
|
||||
let p: Vec<&str> = line.splitn(5, '|').collect();
|
||||
DockerImage {
|
||||
id: p.first().unwrap_or(&"").to_string(),
|
||||
repository: p.get(1).unwrap_or(&"").to_string(),
|
||||
tag: p.get(2).unwrap_or(&"").to_string(),
|
||||
size: p.get(3).unwrap_or(&"").to_string(),
|
||||
created: p.get(4).unwrap_or(&"").to_string(),
|
||||
}
|
||||
}).collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn docker_list_volumes(session_id: String, state: State<'_, AppState>) -> Result<Vec<DockerVolume>, String> {
|
||||
let session = state.ssh.get_session(&session_id).ok_or("Session not found")?;
|
||||
let output = exec(&session.handle, "docker volume ls --format '{{.Name}}|{{.Driver}}|{{.Mountpoint}}' 2>&1").await?;
|
||||
Ok(output.lines().filter(|l| !l.is_empty()).map(|line| {
|
||||
let p: Vec<&str> = line.splitn(3, '|').collect();
|
||||
DockerVolume {
|
||||
name: p.first().unwrap_or(&"").to_string(),
|
||||
driver: p.get(1).unwrap_or(&"").to_string(),
|
||||
mountpoint: p.get(2).unwrap_or(&"").to_string(),
|
||||
}
|
||||
}).collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn docker_action(session_id: String, action: String, target: String, state: State<'_, AppState>) -> Result<String, String> {
|
||||
let session = state.ssh.get_session(&session_id).ok_or("Session not found")?;
|
||||
let cmd = match action.as_str() {
|
||||
"start" => format!("docker start {} 2>&1", target),
|
||||
"stop" => format!("docker stop {} 2>&1", target),
|
||||
"restart" => format!("docker restart {} 2>&1", target),
|
||||
"remove" => format!("docker rm -f {} 2>&1", target),
|
||||
"logs" => format!("docker logs --tail 100 {} 2>&1", target),
|
||||
"remove-image" => format!("docker rmi {} 2>&1", target),
|
||||
"remove-volume" => format!("docker volume rm {} 2>&1", target),
|
||||
"builder-prune" => "docker builder prune -f 2>&1".to_string(),
|
||||
"system-prune" => "docker system prune -f 2>&1".to_string(),
|
||||
"system-prune-all" => "docker system prune -a -f 2>&1".to_string(),
|
||||
_ => return Err(format!("Unknown docker action: {}", action)),
|
||||
};
|
||||
exec(&session.handle, &cmd).await
|
||||
}
|
||||
|
||||
async fn exec(handle: &std::sync::Arc<tokio::sync::Mutex<russh::client::Handle<crate::ssh::session::SshClient>>>, cmd: &str) -> Result<String, String> {
|
||||
let mut channel = { let h = handle.lock().await; h.channel_open_session().await.map_err(|e| format!("Exec failed: {}", e))? };
|
||||
channel.exec(true, cmd).await.map_err(|e| format!("Exec failed: {}", e))?;
|
||||
let mut output = String::new();
|
||||
loop {
|
||||
match channel.wait().await {
|
||||
Some(russh::ChannelMsg::Data { ref data }) => { if let Ok(t) = std::str::from_utf8(data.as_ref()) { output.push_str(t); } }
|
||||
Some(russh::ChannelMsg::Eof) | Some(russh::ChannelMsg::Close) | None => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
@ -12,3 +12,5 @@ pub mod scanner_commands;
|
||||
pub mod tools_commands;
|
||||
pub mod updater;
|
||||
pub mod tools_commands_r2;
|
||||
pub mod workspace_commands;
|
||||
pub mod docker_commands;
|
||||
|
||||
16
src-tauri/src/commands/workspace_commands.rs
Normal file
16
src-tauri/src/commands/workspace_commands.rs
Normal file
@ -0,0 +1,16 @@
|
||||
//! Tauri commands for workspace persistence.
|
||||
|
||||
use tauri::State;
|
||||
use crate::AppState;
|
||||
use crate::workspace::{WorkspaceSnapshot, WorkspaceTab};
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_workspace(tabs: Vec<WorkspaceTab>, state: State<'_, AppState>) -> Result<(), String> {
|
||||
let snapshot = WorkspaceSnapshot { tabs };
|
||||
state.workspace.save(&snapshot)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn load_workspace(state: State<'_, AppState>) -> Result<Option<WorkspaceSnapshot>, String> {
|
||||
Ok(state.workspace.load())
|
||||
}
|
||||
@ -196,6 +196,8 @@ pub fn run() {
|
||||
commands::tools_commands::tool_ping, commands::tools_commands::tool_traceroute, commands::tools_commands::tool_wake_on_lan, commands::tools_commands::tool_generate_ssh_key, commands::tools_commands::tool_generate_password,
|
||||
commands::tools_commands_r2::tool_dns_lookup, commands::tools_commands_r2::tool_whois, commands::tools_commands_r2::tool_bandwidth_iperf, commands::tools_commands_r2::tool_bandwidth_speedtest, commands::tools_commands_r2::tool_subnet_calc,
|
||||
commands::updater::check_for_updates,
|
||||
commands::workspace_commands::save_workspace, commands::workspace_commands::load_workspace,
|
||||
commands::docker_commands::docker_list_containers, commands::docker_commands::docker_list_images, commands::docker_commands::docker_list_volumes, commands::docker_commands::docker_action,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
: 'text-[var(--wraith-text-muted)] hover:text-[var(--wraith-text-secondary)] hover:bg-[var(--wraith-bg-tertiary)]',
|
||||
isRootUser(session) ? 'border-t-2 border-t-[#f8514966]' : '',
|
||||
dragOverIndex === index ? 'border-l-2 border-l-[var(--wraith-accent-blue)]' : '',
|
||||
session.hasActivity && session.id !== sessionStore.activeSessionId ? 'animate-pulse text-[var(--wraith-accent-blue)]' : '',
|
||||
]"
|
||||
@click="sessionStore.activateSession(session.id)"
|
||||
@dragstart="onDragStart(index, $event)"
|
||||
|
||||
@ -28,10 +28,17 @@
|
||||
<!-- Only show groups that have matching connections during search -->
|
||||
<div v-if="!connectionStore.searchQuery || connectionStore.groupHasResults(group.id)">
|
||||
<!-- Group header -->
|
||||
<button
|
||||
class="w-full flex items-center gap-1.5 px-3 py-1.5 text-xs hover:bg-[var(--wraith-bg-tertiary)] transition-colors cursor-pointer"
|
||||
<div
|
||||
class="w-full flex items-center gap-1.5 px-3 py-1.5 text-xs hover:bg-[var(--wraith-bg-tertiary)] transition-colors cursor-pointer select-none"
|
||||
:class="{ 'border-t-2 border-t-[var(--wraith-accent-blue)]': dragOverGroupId === group.id }"
|
||||
draggable="true"
|
||||
@click="toggleGroup(group.id)"
|
||||
@contextmenu.prevent="showGroupMenu($event, group)"
|
||||
@dragstart="onGroupDragStart(group, $event)"
|
||||
@dragover.prevent="onGroupDragOver(group)"
|
||||
@dragleave="dragOverGroupId = null"
|
||||
@drop.prevent="onGroupDrop(group)"
|
||||
@dragend="resetDragState"
|
||||
>
|
||||
<!-- Chevron -->
|
||||
<svg
|
||||
@ -58,16 +65,23 @@
|
||||
<span class="ml-auto text-[var(--wraith-text-muted)] text-[10px]">
|
||||
{{ connectionStore.connectionsByGroup(group.id).length }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Connections in group -->
|
||||
<div v-if="expandedGroups.has(group.id)">
|
||||
<button
|
||||
<div
|
||||
v-for="conn in connectionStore.connectionsByGroup(group.id)"
|
||||
:key="conn.id"
|
||||
class="w-full flex items-center gap-2 pl-8 pr-3 py-1.5 text-xs hover:bg-[var(--wraith-bg-tertiary)] transition-colors cursor-pointer"
|
||||
draggable="true"
|
||||
class="w-full flex items-center gap-2 pl-8 pr-3 py-1.5 text-xs hover:bg-[var(--wraith-bg-tertiary)] transition-colors cursor-pointer select-none"
|
||||
:class="{ 'border-t-2 border-t-[var(--wraith-accent-blue)]': dragOverConnId === conn.id }"
|
||||
@dblclick="handleConnect(conn)"
|
||||
@contextmenu.prevent="showConnectionMenu($event, conn)"
|
||||
@dragstart="onConnDragStart(conn, group.id, $event)"
|
||||
@dragover.prevent="onConnDragOver(conn)"
|
||||
@dragleave="dragOverConnId = null"
|
||||
@drop.prevent="onConnDrop(conn, group.id)"
|
||||
@dragend="resetDragState"
|
||||
>
|
||||
<!-- Protocol dot -->
|
||||
<span
|
||||
@ -82,7 +96,7 @@
|
||||
>
|
||||
{{ tag }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -118,6 +132,102 @@ const sessionStore = useSessionStore();
|
||||
const contextMenu = ref<InstanceType<typeof ContextMenu> | null>(null);
|
||||
const editDialog = ref<InstanceType<typeof ConnectionEditDialog> | null>(null);
|
||||
|
||||
// ── Drag and drop reordering ──────────────────────────────────────────────────
|
||||
|
||||
const dragOverGroupId = ref<number | null>(null);
|
||||
const dragOverConnId = ref<number | null>(null);
|
||||
let draggedGroup: Group | null = null;
|
||||
let draggedConn: { conn: Connection; fromGroupId: number } | null = null;
|
||||
|
||||
function onGroupDragStart(group: Group, event: DragEvent): void {
|
||||
draggedGroup = group;
|
||||
draggedConn = null;
|
||||
event.dataTransfer?.setData("text/plain", `group:${group.id}`);
|
||||
}
|
||||
|
||||
function onGroupDragOver(target: Group): void {
|
||||
if (draggedGroup && draggedGroup.id !== target.id) {
|
||||
dragOverGroupId.value = target.id;
|
||||
}
|
||||
// Allow dropping connections onto groups to move them
|
||||
if (draggedConn) {
|
||||
dragOverGroupId.value = target.id;
|
||||
}
|
||||
}
|
||||
|
||||
async function onGroupDrop(target: Group): Promise<void> {
|
||||
if (draggedGroup && draggedGroup.id !== target.id) {
|
||||
// Reorder groups — swap sort_order
|
||||
const groups = connectionStore.groups;
|
||||
const fromIdx = groups.findIndex(g => g.id === draggedGroup!.id);
|
||||
const toIdx = groups.findIndex(g => g.id === target.id);
|
||||
if (fromIdx !== -1 && toIdx !== -1) {
|
||||
const [moved] = groups.splice(fromIdx, 1);
|
||||
groups.splice(toIdx, 0, moved);
|
||||
}
|
||||
}
|
||||
if (draggedConn && draggedConn.fromGroupId !== target.id) {
|
||||
// Move connection to different group
|
||||
try {
|
||||
await invoke("update_connection", {
|
||||
id: draggedConn.conn.id,
|
||||
input: { groupId: target.id },
|
||||
});
|
||||
await connectionStore.loadAll();
|
||||
} catch (err) {
|
||||
console.error("Failed to move connection:", err);
|
||||
}
|
||||
}
|
||||
resetDragState();
|
||||
}
|
||||
|
||||
function onConnDragStart(conn: Connection, groupId: number, event: DragEvent): void {
|
||||
draggedConn = { conn, fromGroupId: groupId };
|
||||
draggedGroup = null;
|
||||
event.dataTransfer?.setData("text/plain", `conn:${conn.id}`);
|
||||
}
|
||||
|
||||
function onConnDragOver(target: Connection): void {
|
||||
if (draggedConn && draggedConn.conn.id !== target.id) {
|
||||
dragOverConnId.value = target.id;
|
||||
}
|
||||
}
|
||||
|
||||
async function onConnDrop(target: Connection, targetGroupId: number): Promise<void> {
|
||||
if (draggedConn && draggedConn.conn.id !== target.id) {
|
||||
// Reorder within group or move between groups
|
||||
if (draggedConn.fromGroupId !== targetGroupId) {
|
||||
// Move to different group
|
||||
try {
|
||||
await invoke("update_connection", {
|
||||
id: draggedConn.conn.id,
|
||||
input: { groupId: targetGroupId },
|
||||
});
|
||||
await connectionStore.loadAll();
|
||||
} catch (err) {
|
||||
console.error("Failed to move connection:", err);
|
||||
}
|
||||
} else {
|
||||
// Reorder within same group
|
||||
const conns = connectionStore.connectionsByGroup(targetGroupId);
|
||||
const fromIdx = conns.findIndex(c => c.id === draggedConn!.conn.id);
|
||||
const toIdx = conns.findIndex(c => c.id === target.id);
|
||||
if (fromIdx !== -1 && toIdx !== -1) {
|
||||
const [moved] = conns.splice(fromIdx, 1);
|
||||
conns.splice(toIdx, 0, moved);
|
||||
}
|
||||
}
|
||||
}
|
||||
resetDragState();
|
||||
}
|
||||
|
||||
function resetDragState(): void {
|
||||
draggedGroup = null;
|
||||
draggedConn = null;
|
||||
dragOverGroupId.value = null;
|
||||
dragOverConnId.value = null;
|
||||
}
|
||||
|
||||
// All groups expanded by default
|
||||
const expandedGroups = ref<Set<number>>(
|
||||
new Set(connectionStore.groups.map((g) => g.id)),
|
||||
|
||||
113
src/components/tools/DockerPanel.vue
Normal file
113
src/components/tools/DockerPanel.vue
Normal file
@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full p-4 gap-3">
|
||||
<!-- Tabs -->
|
||||
<div class="flex items-center gap-2">
|
||||
<button v-for="t in ['containers','images','volumes']" :key="t"
|
||||
class="px-3 py-1 text-xs rounded cursor-pointer transition-colors"
|
||||
:class="tab === t ? 'bg-[#58a6ff] text-black font-bold' : 'bg-[#21262d] text-[#8b949e] hover:text-white'"
|
||||
@click="tab = t; refresh()"
|
||||
>{{ t.charAt(0).toUpperCase() + t.slice(1) }}</button>
|
||||
|
||||
<div class="ml-auto flex gap-1">
|
||||
<button class="px-2 py-1 text-[10px] rounded bg-[#21262d] text-[#8b949e] hover:text-white cursor-pointer" @click="refresh">Refresh</button>
|
||||
<button class="px-2 py-1 text-[10px] rounded bg-[#da3633] text-white cursor-pointer" @click="action('builder-prune', '')">Builder Prune</button>
|
||||
<button class="px-2 py-1 text-[10px] rounded bg-[#da3633] text-white cursor-pointer" @click="action('system-prune', '')">System Prune</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Containers -->
|
||||
<div v-if="tab === 'containers'" class="flex-1 overflow-auto border border-[#30363d] rounded">
|
||||
<table class="w-full text-xs"><thead class="bg-[#161b22] sticky top-0"><tr>
|
||||
<th class="text-left px-3 py-2 text-[#8b949e]">Name</th>
|
||||
<th class="text-left px-3 py-2 text-[#8b949e]">Image</th>
|
||||
<th class="text-left px-3 py-2 text-[#8b949e]">Status</th>
|
||||
<th class="text-left px-3 py-2 text-[#8b949e]">Actions</th>
|
||||
</tr></thead><tbody>
|
||||
<tr v-for="c in containers" :key="c.id" class="border-t border-[#21262d] hover:bg-[#161b22]">
|
||||
<td class="px-3 py-1.5 font-mono">{{ c.name }}</td>
|
||||
<td class="px-3 py-1.5 text-[#8b949e]">{{ c.image }}</td>
|
||||
<td class="px-3 py-1.5" :class="c.status.startsWith('Up') ? 'text-[#3fb950]' : 'text-[#8b949e]'">{{ c.status }}</td>
|
||||
<td class="px-3 py-1.5 flex gap-1">
|
||||
<button v-if="!c.status.startsWith('Up')" class="px-1.5 py-0.5 text-[10px] rounded bg-[#238636] text-white cursor-pointer" @click="action('start', c.name)">Start</button>
|
||||
<button v-if="c.status.startsWith('Up')" class="px-1.5 py-0.5 text-[10px] rounded bg-[#e3b341] text-black cursor-pointer" @click="action('stop', c.name)">Stop</button>
|
||||
<button class="px-1.5 py-0.5 text-[10px] rounded bg-[#1f6feb] text-white cursor-pointer" @click="action('restart', c.name)">Restart</button>
|
||||
<button class="px-1.5 py-0.5 text-[10px] rounded bg-[#21262d] text-[#8b949e] cursor-pointer" @click="viewLogs(c.name)">Logs</button>
|
||||
<button class="px-1.5 py-0.5 text-[10px] rounded bg-[#da3633] text-white cursor-pointer" @click="action('remove', c.name)">Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
</div>
|
||||
|
||||
<!-- Images -->
|
||||
<div v-if="tab === 'images'" class="flex-1 overflow-auto border border-[#30363d] rounded">
|
||||
<table class="w-full text-xs"><thead class="bg-[#161b22] sticky top-0"><tr>
|
||||
<th class="text-left px-3 py-2 text-[#8b949e]">Repository</th>
|
||||
<th class="text-left px-3 py-2 text-[#8b949e]">Tag</th>
|
||||
<th class="text-left px-3 py-2 text-[#8b949e]">Size</th>
|
||||
<th class="text-left px-3 py-2 text-[#8b949e]">Actions</th>
|
||||
</tr></thead><tbody>
|
||||
<tr v-for="img in images" :key="img.id" class="border-t border-[#21262d] hover:bg-[#161b22]">
|
||||
<td class="px-3 py-1.5 font-mono">{{ img.repository }}</td>
|
||||
<td class="px-3 py-1.5">{{ img.tag }}</td>
|
||||
<td class="px-3 py-1.5 text-[#8b949e]">{{ img.size }}</td>
|
||||
<td class="px-3 py-1.5"><button class="px-1.5 py-0.5 text-[10px] rounded bg-[#da3633] text-white cursor-pointer" @click="action('remove-image', img.id)">Remove</button></td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
</div>
|
||||
|
||||
<!-- Volumes -->
|
||||
<div v-if="tab === 'volumes'" class="flex-1 overflow-auto border border-[#30363d] rounded">
|
||||
<table class="w-full text-xs"><thead class="bg-[#161b22] sticky top-0"><tr>
|
||||
<th class="text-left px-3 py-2 text-[#8b949e]">Name</th>
|
||||
<th class="text-left px-3 py-2 text-[#8b949e]">Driver</th>
|
||||
<th class="text-left px-3 py-2 text-[#8b949e]">Actions</th>
|
||||
</tr></thead><tbody>
|
||||
<tr v-for="v in volumes" :key="v.name" class="border-t border-[#21262d] hover:bg-[#161b22]">
|
||||
<td class="px-3 py-1.5 font-mono">{{ v.name }}</td>
|
||||
<td class="px-3 py-1.5 text-[#8b949e]">{{ v.driver }}</td>
|
||||
<td class="px-3 py-1.5"><button class="px-1.5 py-0.5 text-[10px] rounded bg-[#da3633] text-white cursor-pointer" @click="action('remove-volume', v.name)">Remove</button></td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
</div>
|
||||
|
||||
<!-- Output -->
|
||||
<pre v-if="output" class="max-h-32 overflow-auto bg-[#161b22] border border-[#30363d] rounded p-2 text-[10px] font-mono text-[#e0e0e0]">{{ output }}</pre>
|
||||
|
||||
<div class="text-[10px] text-[#484f58]">{{ containers.length }} containers · {{ images.length }} images · {{ volumes.length }} volumes</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
const props = defineProps<{ sessionId: string }>();
|
||||
|
||||
const tab = ref("containers");
|
||||
const containers = ref<any[]>([]);
|
||||
const images = ref<any[]>([]);
|
||||
const volumes = ref<any[]>([]);
|
||||
const output = ref("");
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
try {
|
||||
if (tab.value === "containers") containers.value = await invoke("docker_list_containers", { sessionId: props.sessionId, all: true });
|
||||
if (tab.value === "images") images.value = await invoke("docker_list_images", { sessionId: props.sessionId });
|
||||
if (tab.value === "volumes") volumes.value = await invoke("docker_list_volumes", { sessionId: props.sessionId });
|
||||
} catch (err) { output.value = String(err); }
|
||||
}
|
||||
|
||||
async function action(act: string, target: string): Promise<void> {
|
||||
try {
|
||||
output.value = await invoke<string>("docker_action", { sessionId: props.sessionId, action: act, target });
|
||||
await refresh();
|
||||
} catch (err) { output.value = String(err); }
|
||||
}
|
||||
|
||||
async function viewLogs(name: string): Promise<void> {
|
||||
try { output.value = await invoke<string>("docker_action", { sessionId: props.sessionId, action: "logs", target: name }); }
|
||||
catch (err) { output.value = String(err); }
|
||||
}
|
||||
|
||||
onMounted(refresh);
|
||||
</script>
|
||||
@ -9,6 +9,7 @@
|
||||
<WhoisTool v-else-if="tool === 'whois'" :session-id="sessionId" />
|
||||
<BandwidthTest v-else-if="tool === 'bandwidth'" :session-id="sessionId" />
|
||||
<SubnetCalc v-else-if="tool === 'subnet-calc'" />
|
||||
<DockerPanel v-else-if="tool === 'docker'" :session-id="sessionId" />
|
||||
<FileEditor v-else-if="tool === 'editor'" :session-id="sessionId" />
|
||||
<SshKeyGen v-else-if="tool === 'ssh-keygen'" />
|
||||
<PasswordGen v-else-if="tool === 'password-gen'" />
|
||||
@ -28,6 +29,7 @@ import DnsLookup from "./DnsLookup.vue";
|
||||
import WhoisTool from "./WhoisTool.vue";
|
||||
import BandwidthTest from "./BandwidthTest.vue";
|
||||
import SubnetCalc from "./SubnetCalc.vue";
|
||||
import DockerPanel from "./DockerPanel.vue";
|
||||
import FileEditor from "./FileEditor.vue";
|
||||
import SshKeyGen from "./SshKeyGen.vue";
|
||||
import PasswordGen from "./PasswordGen.vue";
|
||||
|
||||
@ -5,6 +5,7 @@ import { SearchAddon } from "@xterm/addon-search";
|
||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||
import { useSessionStore } from "@/stores/session.store";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
|
||||
/** MobaXTerm Classic–inspired terminal theme colors. */
|
||||
@ -161,6 +162,9 @@ export function useTerminal(sessionId: string, backend: 'ssh' | 'pty' = 'ssh'):
|
||||
// Tauri v2 listen() callback receives { payload: T } — the base64 string
|
||||
// is in event.payload (not event.data as in Wails).
|
||||
unlistenPromise = listen<string>(dataEvent, (event) => {
|
||||
// Mark tab activity for background sessions
|
||||
try { useSessionStore().markActivity(sessionId); } catch {}
|
||||
|
||||
const b64data = event.payload;
|
||||
|
||||
try {
|
||||
|
||||
@ -113,6 +113,13 @@
|
||||
<span class="flex-1">Subnet Calculator</span>
|
||||
</button>
|
||||
<div class="border-t border-[#30363d] my-1" />
|
||||
<button
|
||||
class="w-full flex items-center gap-3 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
|
||||
@mousedown.prevent="handleToolAction('docker')"
|
||||
>
|
||||
<span class="flex-1">Docker Manager</span>
|
||||
</button>
|
||||
<div class="border-t border-[#30363d] my-1" />
|
||||
<button
|
||||
class="w-full flex items-center gap-3 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
|
||||
@mousedown.prevent="handleToolAction('wake-on-lan')"
|
||||
@ -333,6 +340,7 @@ async function handleToolAction(tool: string): Promise<void> {
|
||||
"whois": { title: "Whois", width: 700, height: 500 },
|
||||
"bandwidth": { title: "Bandwidth Test", width: 700, height: 450 },
|
||||
"subnet-calc": { title: "Subnet Calculator", width: 650, height: 350 },
|
||||
"docker": { title: "Docker Manager", width: 900, height: 600 },
|
||||
"wake-on-lan": { title: "Wake on LAN", width: 500, height: 300 },
|
||||
"ssh-keygen": { title: "SSH Key Generator", width: 700, height: 500 },
|
||||
"password-gen": { title: "Password Generator", width: 500, height: 400 },
|
||||
@ -430,6 +438,25 @@ onMounted(async () => {
|
||||
document.addEventListener("keydown", handleKeydown);
|
||||
await connectionStore.loadAll();
|
||||
|
||||
// Restore workspace — reconnect saved tabs
|
||||
try {
|
||||
const workspace = await invoke<{ tabs: { connectionId: number; protocol: string; position: number }[] } | null>("load_workspace");
|
||||
if (workspace?.tabs?.length) {
|
||||
for (const tab of workspace.tabs.sort((a, b) => a.position - b.position)) {
|
||||
sessionStore.connect(tab.connectionId).catch(() => {});
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Save workspace on window close
|
||||
const appWindow = await import("@tauri-apps/api/window").then(m => m.getCurrentWindow());
|
||||
appWindow.onCloseRequested(async () => {
|
||||
const tabs = sessionStore.sessions
|
||||
.filter(s => s.protocol === "ssh" || s.protocol === "rdp")
|
||||
.map((s, i) => ({ connectionId: s.connectionId, protocol: s.protocol, position: i }));
|
||||
await invoke("save_workspace", { tabs }).catch(() => {});
|
||||
});
|
||||
|
||||
// Check for updates on startup (non-blocking)
|
||||
invoke<{ currentVersion: string; latestVersion: string; updateAvailable: boolean; downloadUrl: string }>("check_for_updates")
|
||||
.then((info) => {
|
||||
|
||||
@ -13,6 +13,7 @@ export interface Session {
|
||||
active: boolean;
|
||||
username?: string;
|
||||
status: "connected" | "disconnected";
|
||||
hasActivity: boolean;
|
||||
}
|
||||
|
||||
export interface TerminalDimensions {
|
||||
@ -51,6 +52,16 @@ export const useSessionStore = defineStore("session", () => {
|
||||
|
||||
function activateSession(id: string): void {
|
||||
activeSessionId.value = id;
|
||||
// Clear activity indicator when switching to tab
|
||||
const session = sessions.value.find(s => s.id === id);
|
||||
if (session) session.hasActivity = false;
|
||||
}
|
||||
|
||||
/** Mark a background tab as having new activity. */
|
||||
function markActivity(sessionId: string): void {
|
||||
if (sessionId === activeSessionId.value) return; // don't flash the active tab
|
||||
const session = sessions.value.find(s => s.id === sessionId);
|
||||
if (session) session.hasActivity = true;
|
||||
}
|
||||
|
||||
/** Reorder sessions by moving a tab from one index to another. */
|
||||
@ -155,6 +166,7 @@ export const useSessionStore = defineStore("session", () => {
|
||||
active: true,
|
||||
username: resolvedUsername,
|
||||
status: "connected",
|
||||
hasActivity: false,
|
||||
});
|
||||
setupStatusListeners(sessionId);
|
||||
activeSessionId.value = sessionId;
|
||||
@ -219,6 +231,7 @@ export const useSessionStore = defineStore("session", () => {
|
||||
active: true,
|
||||
username: resolvedUsername,
|
||||
status: "connected",
|
||||
hasActivity: false,
|
||||
});
|
||||
setupStatusListeners(sessionId);
|
||||
activeSessionId.value = sessionId;
|
||||
@ -303,6 +316,7 @@ export const useSessionStore = defineStore("session", () => {
|
||||
active: true,
|
||||
username,
|
||||
status: "connected",
|
||||
hasActivity: false,
|
||||
});
|
||||
activeSessionId.value = sessionId;
|
||||
}
|
||||
@ -333,6 +347,7 @@ export const useSessionStore = defineStore("session", () => {
|
||||
protocol: "local",
|
||||
active: true,
|
||||
status: "connected",
|
||||
hasActivity: false,
|
||||
});
|
||||
|
||||
// Listen for PTY close
|
||||
@ -376,6 +391,7 @@ export const useSessionStore = defineStore("session", () => {
|
||||
connect,
|
||||
spawnLocalTab,
|
||||
moveSession,
|
||||
markActivity,
|
||||
setTheme,
|
||||
setTerminalDimensions,
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user