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 tools_commands;
|
||||||
pub mod updater;
|
pub mod updater;
|
||||||
pub mod tools_commands_r2;
|
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::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::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::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!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.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)]',
|
: '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]' : '',
|
isRootUser(session) ? 'border-t-2 border-t-[#f8514966]' : '',
|
||||||
dragOverIndex === index ? 'border-l-2 border-l-[var(--wraith-accent-blue)]' : '',
|
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)"
|
@click="sessionStore.activateSession(session.id)"
|
||||||
@dragstart="onDragStart(index, $event)"
|
@dragstart="onDragStart(index, $event)"
|
||||||
|
|||||||
@ -28,10 +28,17 @@
|
|||||||
<!-- Only show groups that have matching connections during search -->
|
<!-- Only show groups that have matching connections during search -->
|
||||||
<div v-if="!connectionStore.searchQuery || connectionStore.groupHasResults(group.id)">
|
<div v-if="!connectionStore.searchQuery || connectionStore.groupHasResults(group.id)">
|
||||||
<!-- Group header -->
|
<!-- Group header -->
|
||||||
<button
|
<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"
|
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)"
|
@click="toggleGroup(group.id)"
|
||||||
@contextmenu.prevent="showGroupMenu($event, group)"
|
@contextmenu.prevent="showGroupMenu($event, group)"
|
||||||
|
@dragstart="onGroupDragStart(group, $event)"
|
||||||
|
@dragover.prevent="onGroupDragOver(group)"
|
||||||
|
@dragleave="dragOverGroupId = null"
|
||||||
|
@drop.prevent="onGroupDrop(group)"
|
||||||
|
@dragend="resetDragState"
|
||||||
>
|
>
|
||||||
<!-- Chevron -->
|
<!-- Chevron -->
|
||||||
<svg
|
<svg
|
||||||
@ -58,16 +65,23 @@
|
|||||||
<span class="ml-auto text-[var(--wraith-text-muted)] text-[10px]">
|
<span class="ml-auto text-[var(--wraith-text-muted)] text-[10px]">
|
||||||
{{ connectionStore.connectionsByGroup(group.id).length }}
|
{{ connectionStore.connectionsByGroup(group.id).length }}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</div>
|
||||||
|
|
||||||
<!-- Connections in group -->
|
<!-- Connections in group -->
|
||||||
<div v-if="expandedGroups.has(group.id)">
|
<div v-if="expandedGroups.has(group.id)">
|
||||||
<button
|
<div
|
||||||
v-for="conn in connectionStore.connectionsByGroup(group.id)"
|
v-for="conn in connectionStore.connectionsByGroup(group.id)"
|
||||||
:key="conn.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)"
|
@dblclick="handleConnect(conn)"
|
||||||
@contextmenu.prevent="showConnectionMenu($event, 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 -->
|
<!-- Protocol dot -->
|
||||||
<span
|
<span
|
||||||
@ -82,7 +96,7 @@
|
|||||||
>
|
>
|
||||||
{{ tag }}
|
{{ tag }}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -118,6 +132,102 @@ const sessionStore = useSessionStore();
|
|||||||
const contextMenu = ref<InstanceType<typeof ContextMenu> | null>(null);
|
const contextMenu = ref<InstanceType<typeof ContextMenu> | null>(null);
|
||||||
const editDialog = ref<InstanceType<typeof ConnectionEditDialog> | 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
|
// All groups expanded by default
|
||||||
const expandedGroups = ref<Set<number>>(
|
const expandedGroups = ref<Set<number>>(
|
||||||
new Set(connectionStore.groups.map((g) => g.id)),
|
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" />
|
<WhoisTool v-else-if="tool === 'whois'" :session-id="sessionId" />
|
||||||
<BandwidthTest v-else-if="tool === 'bandwidth'" :session-id="sessionId" />
|
<BandwidthTest v-else-if="tool === 'bandwidth'" :session-id="sessionId" />
|
||||||
<SubnetCalc v-else-if="tool === 'subnet-calc'" />
|
<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" />
|
<FileEditor v-else-if="tool === 'editor'" :session-id="sessionId" />
|
||||||
<SshKeyGen v-else-if="tool === 'ssh-keygen'" />
|
<SshKeyGen v-else-if="tool === 'ssh-keygen'" />
|
||||||
<PasswordGen v-else-if="tool === 'password-gen'" />
|
<PasswordGen v-else-if="tool === 'password-gen'" />
|
||||||
@ -28,6 +29,7 @@ import DnsLookup from "./DnsLookup.vue";
|
|||||||
import WhoisTool from "./WhoisTool.vue";
|
import WhoisTool from "./WhoisTool.vue";
|
||||||
import BandwidthTest from "./BandwidthTest.vue";
|
import BandwidthTest from "./BandwidthTest.vue";
|
||||||
import SubnetCalc from "./SubnetCalc.vue";
|
import SubnetCalc from "./SubnetCalc.vue";
|
||||||
|
import DockerPanel from "./DockerPanel.vue";
|
||||||
import FileEditor from "./FileEditor.vue";
|
import FileEditor from "./FileEditor.vue";
|
||||||
import SshKeyGen from "./SshKeyGen.vue";
|
import SshKeyGen from "./SshKeyGen.vue";
|
||||||
import PasswordGen from "./PasswordGen.vue";
|
import PasswordGen from "./PasswordGen.vue";
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { SearchAddon } from "@xterm/addon-search";
|
|||||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||||
|
import { useSessionStore } from "@/stores/session.store";
|
||||||
import "@xterm/xterm/css/xterm.css";
|
import "@xterm/xterm/css/xterm.css";
|
||||||
|
|
||||||
/** MobaXTerm Classic–inspired terminal theme colors. */
|
/** 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
|
// Tauri v2 listen() callback receives { payload: T } — the base64 string
|
||||||
// is in event.payload (not event.data as in Wails).
|
// is in event.payload (not event.data as in Wails).
|
||||||
unlistenPromise = listen<string>(dataEvent, (event) => {
|
unlistenPromise = listen<string>(dataEvent, (event) => {
|
||||||
|
// Mark tab activity for background sessions
|
||||||
|
try { useSessionStore().markActivity(sessionId); } catch {}
|
||||||
|
|
||||||
const b64data = event.payload;
|
const b64data = event.payload;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -113,6 +113,13 @@
|
|||||||
<span class="flex-1">Subnet Calculator</span>
|
<span class="flex-1">Subnet Calculator</span>
|
||||||
</button>
|
</button>
|
||||||
<div class="border-t border-[#30363d] my-1" />
|
<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
|
<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"
|
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')"
|
@mousedown.prevent="handleToolAction('wake-on-lan')"
|
||||||
@ -333,6 +340,7 @@ async function handleToolAction(tool: string): Promise<void> {
|
|||||||
"whois": { title: "Whois", width: 700, height: 500 },
|
"whois": { title: "Whois", width: 700, height: 500 },
|
||||||
"bandwidth": { title: "Bandwidth Test", width: 700, height: 450 },
|
"bandwidth": { title: "Bandwidth Test", width: 700, height: 450 },
|
||||||
"subnet-calc": { title: "Subnet Calculator", width: 650, height: 350 },
|
"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 },
|
"wake-on-lan": { title: "Wake on LAN", width: 500, height: 300 },
|
||||||
"ssh-keygen": { title: "SSH Key Generator", width: 700, height: 500 },
|
"ssh-keygen": { title: "SSH Key Generator", width: 700, height: 500 },
|
||||||
"password-gen": { title: "Password Generator", width: 500, height: 400 },
|
"password-gen": { title: "Password Generator", width: 500, height: 400 },
|
||||||
@ -430,6 +438,25 @@ onMounted(async () => {
|
|||||||
document.addEventListener("keydown", handleKeydown);
|
document.addEventListener("keydown", handleKeydown);
|
||||||
await connectionStore.loadAll();
|
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)
|
// Check for updates on startup (non-blocking)
|
||||||
invoke<{ currentVersion: string; latestVersion: string; updateAvailable: boolean; downloadUrl: string }>("check_for_updates")
|
invoke<{ currentVersion: string; latestVersion: string; updateAvailable: boolean; downloadUrl: string }>("check_for_updates")
|
||||||
.then((info) => {
|
.then((info) => {
|
||||||
|
|||||||
@ -13,6 +13,7 @@ export interface Session {
|
|||||||
active: boolean;
|
active: boolean;
|
||||||
username?: string;
|
username?: string;
|
||||||
status: "connected" | "disconnected";
|
status: "connected" | "disconnected";
|
||||||
|
hasActivity: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TerminalDimensions {
|
export interface TerminalDimensions {
|
||||||
@ -51,6 +52,16 @@ export const useSessionStore = defineStore("session", () => {
|
|||||||
|
|
||||||
function activateSession(id: string): void {
|
function activateSession(id: string): void {
|
||||||
activeSessionId.value = id;
|
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. */
|
/** Reorder sessions by moving a tab from one index to another. */
|
||||||
@ -155,6 +166,7 @@ export const useSessionStore = defineStore("session", () => {
|
|||||||
active: true,
|
active: true,
|
||||||
username: resolvedUsername,
|
username: resolvedUsername,
|
||||||
status: "connected",
|
status: "connected",
|
||||||
|
hasActivity: false,
|
||||||
});
|
});
|
||||||
setupStatusListeners(sessionId);
|
setupStatusListeners(sessionId);
|
||||||
activeSessionId.value = sessionId;
|
activeSessionId.value = sessionId;
|
||||||
@ -219,6 +231,7 @@ export const useSessionStore = defineStore("session", () => {
|
|||||||
active: true,
|
active: true,
|
||||||
username: resolvedUsername,
|
username: resolvedUsername,
|
||||||
status: "connected",
|
status: "connected",
|
||||||
|
hasActivity: false,
|
||||||
});
|
});
|
||||||
setupStatusListeners(sessionId);
|
setupStatusListeners(sessionId);
|
||||||
activeSessionId.value = sessionId;
|
activeSessionId.value = sessionId;
|
||||||
@ -303,6 +316,7 @@ export const useSessionStore = defineStore("session", () => {
|
|||||||
active: true,
|
active: true,
|
||||||
username,
|
username,
|
||||||
status: "connected",
|
status: "connected",
|
||||||
|
hasActivity: false,
|
||||||
});
|
});
|
||||||
activeSessionId.value = sessionId;
|
activeSessionId.value = sessionId;
|
||||||
}
|
}
|
||||||
@ -333,6 +347,7 @@ export const useSessionStore = defineStore("session", () => {
|
|||||||
protocol: "local",
|
protocol: "local",
|
||||||
active: true,
|
active: true,
|
||||||
status: "connected",
|
status: "connected",
|
||||||
|
hasActivity: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Listen for PTY close
|
// Listen for PTY close
|
||||||
@ -376,6 +391,7 @@ export const useSessionStore = defineStore("session", () => {
|
|||||||
connect,
|
connect,
|
||||||
spawnLocalTab,
|
spawnLocalTab,
|
||||||
moveSession,
|
moveSession,
|
||||||
|
markActivity,
|
||||||
setTheme,
|
setTheme,
|
||||||
setTerminalDimensions,
|
setTerminalDimensions,
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user