Skip to content

Commit 4550a64

Browse files
authored
Merge pull request #39 from fahadkhan-fk/feature-stream
Live stream command support
2 parents ee7967c + 977411e commit 4550a64

3 files changed

Lines changed: 239 additions & 25 deletions

File tree

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
<template>
2+
<q-card-section class="q-px-xs q-pb-md q-pt-xs" v-if="hasText">
3+
<script-output-copy-clip label="Live Output" :data="outputText" />
4+
<q-separator class="q-my-sm" />
5+
</q-card-section>
6+
<div class="command-stream" ref="streamContainer" v-if="hasText">
7+
<div class="terminal">
8+
<pre class="mt-0">{{ outputText }}</pre>
9+
</div>
10+
</div>
11+
</template>
12+
13+
<script setup lang="ts">
14+
import {
15+
ref,
16+
onMounted,
17+
onUnmounted,
18+
watchEffect,
19+
nextTick,
20+
computed,
21+
} from "vue";
22+
import { useTemplateRef } from "vue";
23+
import { useAgentCmdWSConnection } from "@/websocket/agent";
24+
import ScriptOutputCopyClip from "@/components/scripts/ScriptOutputCopyClip.vue";
25+
import { uid } from "quasar";
26+
27+
const props = defineProps({
28+
agentId: { type: String, required: true },
29+
cmd: { type: String, required: true },
30+
shell: { type: String, required: true },
31+
timeout: { type: Number, default: 10 },
32+
});
33+
34+
// emits
35+
const emit = defineEmits(["updateOutput", "streamLoaded", "streamClosed"]);
36+
37+
const cmdId = uid();
38+
const { send, data, reset, close, status } = useAgentCmdWSConnection(
39+
props.agentId,
40+
cmdId,
41+
);
42+
43+
const outputText = ref("");
44+
const streamContainer = useTemplateRef<HTMLElement>("streamContainer");
45+
let firstChunk = false;
46+
47+
const hasText = computed(() => outputText.value.trim() !== "");
48+
49+
watchEffect(() => {
50+
if (data.value.length) {
51+
outputText.value = data.value.map((msg) => msg.output).join("\n");
52+
emit("updateOutput", outputText.value);
53+
54+
if (!firstChunk && data.value.length > 0) {
55+
firstChunk = true;
56+
emit("streamLoaded");
57+
}
58+
59+
nextTick(() => {
60+
if (streamContainer.value) {
61+
streamContainer.value.scrollTop = streamContainer.value.scrollHeight;
62+
}
63+
});
64+
}
65+
});
66+
67+
watchEffect(() => {
68+
if (status.value === "CLOSED") emit("streamClosed");
69+
});
70+
71+
onMounted(() => {
72+
outputText.value = "";
73+
reset();
74+
send(
75+
JSON.stringify({
76+
shell: props.shell,
77+
cmd: props.cmd,
78+
timeout: props.timeout,
79+
run_as_user: false,
80+
custom_shell: "",
81+
stream: true,
82+
cmd_id: cmdId,
83+
}),
84+
);
85+
});
86+
87+
onUnmounted(close);
88+
</script>
89+
90+
<style scoped>
91+
.command-stream {
92+
font-family: monospace;
93+
background: #191818;
94+
color: #fff;
95+
padding: 0 10px;
96+
height: 30vh;
97+
overflow-y: auto;
98+
border: 1px solid #ccc;
99+
border-radius: 4px;
100+
}
101+
.terminal {
102+
white-space: pre-wrap;
103+
}
104+
</style>

src/components/modals/agents/SendCommand.vue

Lines changed: 61 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
>
88
<q-card
99
class="q-dialog-plugin"
10-
:style="{ 'min-width': !ret ? '40vw' : '70vw' }"
10+
:style="{ 'min-width': ret || streamOutput ? '70vw' : '40vw' }"
1111
>
1212
<q-bar>
1313
Send command on {{ agent.hostname }}
@@ -82,7 +82,7 @@
8282
]"
8383
/>
8484
</q-card-section>
85-
<q-card-section>
85+
<q-card-section class="q-pb-xs">
8686
<q-input
8787
v-model="state.cmd"
8888
outlined
@@ -92,17 +92,20 @@
9292
:rules="[(val) => !!val || '*Required']"
9393
/>
9494
</q-card-section>
95-
<q-card-actions align="right">
96-
<q-btn flat dense push label="Cancel" v-close-popup />
97-
<q-btn
98-
:loading="loading"
99-
flat
100-
dense
101-
push
102-
label="Send"
103-
color="primary"
104-
type="submit"
105-
/>
95+
<q-card-actions align="between">
96+
<q-toggle v-model="useStreaming" label="Stream Output" />
97+
<div>
98+
<q-btn flat dense push label="Cancel" v-close-popup />
99+
<q-btn
100+
:loading="loading"
101+
flat
102+
dense
103+
push
104+
label="Send"
105+
color="primary"
106+
type="submit"
107+
/>
108+
</div>
106109
</q-card-actions>
107110
<q-card-section v-if="ret !== null"
108111
><script-output-copy-clip label="Output" :data="ret" /> <q-separator
@@ -114,35 +117,47 @@
114117
>
115118
<pre>{{ ret }}</pre>
116119
</q-card-section>
120+
<q-card-section v-if="showStream" class="q-py-xs">
121+
<command-stream
122+
:key="`${runId}`"
123+
:agent-id="agent.agent_id"
124+
:cmd="streamCmd"
125+
:shell="state.shell"
126+
:timeout="state.timeout"
127+
@updateOutput="(val) => (streamOutput = val)"
128+
@streamLoaded="loading = false"
129+
@streamClosed="loading = false"
130+
/>
131+
</q-card-section>
117132
</q-form>
118133
</q-card>
119134
</q-dialog>
120135
</template>
121136

122137
<script>
123138
// composition imports
124-
import { ref } from "vue";
139+
import { ref, nextTick } from "vue";
125140
import { useDialogPluginComponent } from "quasar";
126141
import { sendAgentCommand } from "@/api/agents";
127142
import { cmdPlaceholder } from "@/composables/agents";
128143
import { runAsUserToolTip } from "@/constants/constants";
129144
130145
import ScriptOutputCopyClip from "@/components/scripts/ScriptOutputCopyClip.vue";
146+
import CommandStream from "@/components/agents/CommandStream.vue";
131147
132148
export default {
133149
name: "SendCommand",
134150
components: {
135151
ScriptOutputCopyClip,
152+
CommandStream,
136153
},
137154
emits: [...useDialogPluginComponent.emits],
138155
props: {
139156
agent: !Object,
140157
},
141158
setup(props) {
142-
// setup quasar dialog plugin
143159
const { dialogRef, onDialogHide } = useDialogPluginComponent();
144160
145-
// run command logic
146161
const state = ref({
147162
shell: props.agent.plat === "windows" ? "cmd" : "/bin/bash",
148163
cmd: null,
@@ -153,10 +168,30 @@ export default {
153168
154169
const loading = ref(false);
155170
const ret = ref(null);
171+
const useStreaming = ref(false);
172+
const showStream = ref(false);
173+
const streamOutput = ref("");
174+
const streamCmd = ref("");
175+
const streamShell = ref("");
176+
const streamTimeout = ref(30);
177+
const runId = ref(0);
156178
157179
async function submit() {
158180
loading.value = true;
159181
ret.value = null;
182+
streamOutput.value = "";
183+
showStream.value = false;
184+
185+
if (useStreaming.value) {
186+
streamCmd.value = state.value.cmd;
187+
streamShell.value = state.value.shell;
188+
streamTimeout.value = state.value.timeout;
189+
await nextTick();
190+
runId.value++;
191+
showStream.value = true;
192+
return;
193+
}
194+
160195
try {
161196
ret.value = await sendAgentCommand(props.agent.agent_id, state.value);
162197
} catch (e) {
@@ -167,20 +202,21 @@ export default {
167202
168203
return {
169204
// reactive data
205+
dialogRef,
206+
onDialogHide,
170207
state,
171208
loading,
172209
ret,
173-
174-
// non reactivete data
175-
runAsUserToolTip,
176-
177-
// methods
210+
useStreaming,
211+
streamOutput,
212+
showStream,
213+
streamCmd,
214+
streamShell,
215+
streamTimeout,
216+
runId,
178217
submit,
218+
runAsUserToolTip,
179219
cmdPlaceholder,
180-
181-
// quasar dialog
182-
dialogRef,
183-
onDialogHide,
184220
};
185221
},
186222
};

src/websocket/agent.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { ref } from "vue";
2+
import { useWebSocket } from "@vueuse/core";
3+
import { useAuthStore } from "@/stores/auth";
4+
import { getWSUrl } from "./websocket";
5+
import { Notify } from "quasar";
6+
interface CmdMessage {
7+
cmd_id: string;
8+
output?: string;
9+
done?: boolean;
10+
exit_code?: number;
11+
[key: string]: unknown;
12+
}
13+
14+
export function useAgentCmdWSConnection(agentId: string, cmdId: string) {
15+
const auth = useAuthStore();
16+
const lines = ref<CmdMessage[]>([]);
17+
const url = getWSUrl(`agent/${agentId}/cmd`, auth.token);
18+
const ws = useWebSocket(url, {
19+
autoReconnect: false,
20+
onMessage(_, ev) {
21+
try {
22+
const parsed = JSON.parse(ev.data);
23+
if (parsed?.error) {
24+
const msg = `${parsed.error ? parsed.error : "Unknown WebSocket error"}`;
25+
const caption = parsed.status
26+
? `${parsed.status}: Forbidden`
27+
: "Error";
28+
Notify.create({
29+
message: msg,
30+
color: "negative",
31+
position: "top",
32+
caption,
33+
timeout: 4000,
34+
});
35+
ws.close();
36+
return;
37+
}
38+
if (parsed?.cmd_id !== cmdId) return;
39+
if (
40+
parsed?.output != null &&
41+
!(
42+
(lines.value.length === 0 || lines.value.length === 1) &&
43+
parsed.output.trim() === ""
44+
)
45+
) {
46+
lines.value.push(parsed);
47+
}
48+
} catch {
49+
lines.value.push({
50+
cmd_id,
51+
output: "[Error] Unable to parse server output",
52+
});
53+
}
54+
},
55+
});
56+
57+
function reset() {
58+
lines.value = [];
59+
}
60+
61+
function closeConnection() {
62+
ws.close();
63+
lines.value = [];
64+
}
65+
66+
return {
67+
status: ws.status,
68+
data: lines,
69+
send: ws.send,
70+
open: ws.open,
71+
reset,
72+
close: closeConnection,
73+
};
74+
}

0 commit comments

Comments
 (0)