Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,10 @@
"@xterm/addon-web-links": "^0.11.0",
"@xterm/xterm": "^5.5.0",
"axios": "^1.6.7",
"diff": "^9.0.0",
"dompurify": "^3.4.8",
"lucide-vue-next": "^0.554.0",
"marked": "^18.0.5",
"pinia": "^2.1.7",
"primeicons": "^6.0.1",
"primevue": "^3.50.0",
Expand Down
4 changes: 4 additions & 0 deletions src/App.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
<template>
<div id="app">
<ToastNotifications />
<PlanFlowHost />
<AssistHost />
<div v-if="!ready" class="app-loading">
<Logo variant="icon" size="lg" />
</div>
Expand All @@ -11,6 +13,8 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import ToastNotifications from "@/components/ToastNotifications.vue";
import PlanFlowHost from "@/components/plan/PlanFlowHost.vue";
import AssistHost from "@/components/ai/AssistHost.vue";
import Logo from "@/components/base/Logo.vue";
import { useSetupStore } from "@/stores/setup";
import { useRouter } from "vue-router";
Expand Down
30 changes: 20 additions & 10 deletions src/components/DomainsManager.vue
Original file line number Diff line number Diff line change
Expand Up @@ -90,10 +90,12 @@ import { ref, computed, onMounted, watch } from "vue";
import type { DomainConfig, Service, ProxyStatus, Certificate } from "@/types";
import { deploymentsApi, certificatesApi } from "@/services/api";
import { useNotificationsStore } from "@/stores/notifications";
import { usePlanFlow } from "@/composables/usePlanFlow";
import DomainFormModal from "./DomainFormModal.vue";
import ConfirmModal from "./ConfirmModal.vue";

const notifications = useNotificationsStore();
const { runGuarded } = usePlanFlow();

const props = defineProps<{
deploymentName: string;
Expand Down Expand Up @@ -175,19 +177,25 @@ function closeModals() {
async function handleSaveDomain(domain: DomainConfig) {
saving.value = true;
try {
if (editingDomain.value && editingDomain.value.id) {
await deploymentsApi.updateDomain(props.deploymentName, editingDomain.value.id, domain);
const isUpdate = Boolean(editingDomain.value && editingDomain.value.id);
const callDomainApi = (opts?: { plan?: boolean }) =>
isUpdate
? deploymentsApi.updateDomain(props.deploymentName, editingDomain.value!.id, domain, opts)
: deploymentsApi.addDomain(props.deploymentName, domain, opts);
const result = await runGuarded(
() => callDomainApi(),
() => callDomainApi({ plan: true }),
"Save Failed",
);
if (result === false) return;
if (isUpdate) {
notifications.success("Domain Updated", `${domain.domain} has been updated`);
} else {
await deploymentsApi.addDomain(props.deploymentName, domain);
notifications.success("Domain Added", `${domain.domain} has been added`);
}
await fetchDomains();
closeModals();
emit("updated");
} catch (err: any) {
const msg = err.response?.data?.error || err.message;
notifications.error("Save Failed", msg);
} finally {
saving.value = false;
}
Expand All @@ -197,15 +205,17 @@ async function handleDeleteDomain() {
if (!deletingDomain.value) return;
deleting.value = true;
try {
await deploymentsApi.deleteDomain(props.deploymentName, deletingDomain.value.id);
const result = await runGuarded(
() => deploymentsApi.deleteDomain(props.deploymentName, deletingDomain.value!.id),
() => deploymentsApi.deleteDomain(props.deploymentName, deletingDomain.value!.id, { plan: true }),
"Delete Failed",
);
if (result === false) return;
notifications.success("Domain Deleted", `${deletingDomain.value.domain} has been removed`);
await fetchDomains();
showDeleteModal.value = false;
deletingDomain.value = null;
emit("updated");
} catch (err: any) {
const msg = err.response?.data?.error || err.message;
notifications.error("Delete Failed", msg);
} finally {
deleting.value = false;
}
Expand Down
41 changes: 41 additions & 0 deletions src/components/LogViewer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@
<i class="pi pi-arrow-down" />
Follow
</label>
<button
v-if="logs"
class="toolbar-btn ai-btn"
title="Analyze these logs with the AI assistant"
@click="openAssist"
>
<Sparkles :size="14" />
</button>
<button class="toolbar-btn" title="Search (Ctrl+F)" @click="toggleSearch">
<i class="pi pi-search" />
</button>
Expand Down Expand Up @@ -64,6 +72,8 @@ import { FitAddon } from "@xterm/addon-fit";
import { SearchAddon } from "@xterm/addon-search";
import { WebLinksAddon } from "@xterm/addon-web-links";
import "@xterm/xterm/css/xterm.css";
import { Sparkles } from "lucide-vue-next";
import { useAssistStore, type AssistContext } from "@/stores/assist";

const props = withDefaults(
defineProps<{
Expand All @@ -74,6 +84,7 @@ const props = withDefaults(
theme?: "dark" | "light";
fontSize?: number;
lineHeight?: number;
assistContext?: AssistContext | null;
}>(),
{
logs: "",
Expand All @@ -83,13 +94,43 @@ const props = withDefaults(
theme: "dark",
fontSize: 13,
lineHeight: 1.4,
assistContext: null,
},
);

defineEmits<{
refresh: [];
}>();

// Every surface that renders logs gets the AI action for free. A
// parent can pass a richer context (deployment scope unlocks gathered
// sources and runnable suggestions); without one the visible logs are
// analyzed as host-level output.
// The viewer already holds the logs on screen, so it hands them to the
// assistant directly: the model analyzes what the user is looking at
// instead of hunting for it with tools. The parent context only
// supplies scope/deployment/subject.
const openAssist = () => {
const store = useAssistStore();
const base = props.assistContext ?? { scope: "system" as const, subject: props.fileName.replace(/\.txt$/, "") };
if (base.seedMessage) {
store.open(base);
return;
}
if (props.logs) {
store.open({
...base,
seedMessage: `Analyze the recent logs for ${base.subject}: summarize what they show, report any problems with potential solutions, and say so plainly if everything looks normal.`,
seedContext: `\`\`\`\n${props.logs}\n\`\`\``,
});
} else {
store.open({
...base,
seedMessage: `Review the recent logs for ${base.subject}, summarize them and report any problems with potential solutions.`,
});
}
};

const terminalContainer = ref<HTMLElement | null>(null);
const autoScroll = ref(true);
const showSearch = ref(false);
Expand Down
12 changes: 12 additions & 0 deletions src/components/OperationModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
empty-message="Waiting for output..."
:file-name="`${deploymentName}-${operation}.txt`"
:max-height="300"
:assist-context="isRunning ? null : assistContext"
/>
</div>

Expand All @@ -39,6 +40,7 @@
import { ref, computed, watch } from "vue";
import BaseModal from "./base/BaseModal.vue";
import LogViewer from "./LogViewer.vue";
import type { AssistContext } from "@/stores/assist";

const props = defineProps<{
visible: boolean;
Expand All @@ -51,6 +53,16 @@ const props = defineProps<{

const emit = defineEmits(["close"]);

const assistContext = computed<AssistContext>(() => ({
scope: "deployment",
deployment: props.deploymentName,
subject: props.deploymentName,
seedMessage: `The "${props.operation}" operation just ${
props.isSuccess ? "finished" : "failed"
}. Explain what happened${props.isSuccess ? "" : " and how to fix it"}.`,
seedContext: `\`\`\`\n${props.output || "(no output captured)"}\n\`\`\``,
}));

const startTime = ref<number | null>(null);
const elapsedTime = ref("0s");

Expand Down
26 changes: 26 additions & 0 deletions src/components/ai/AssistButton.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<template>
<button class="btn btn-sm btn-secondary" :title="title" @click="open"><Sparkles :size="14" /> {{ label }}</button>
</template>

<script setup lang="ts">
import { Sparkles } from "lucide-vue-next";
import { useAssistStore, type AssistContext } from "@/stores/assist";

const props = withDefaults(
defineProps<{
context: AssistContext;
label?: string;
title?: string;
}>(),
{
label: "Ask AI",
title: "Analyze this with the AI assistant",
},
);

const store = useAssistStore();

const open = () => {
store.open(props.context);
};
</script>
Loading
Loading