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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@
# API base URL — defaults to "/api" (works with nginx proxy in production)
# For local development, point to the agent directly:
VITE_API_URL=http://localhost:8090/api

6 changes: 6 additions & 0 deletions src/components/database/DatabaseDetails.vue
Original file line number Diff line number Diff line change
Expand Up @@ -544,6 +544,12 @@ const filteredAllDatabases = computed(() => {
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: var(--text-sm);
background: var(--surface);
color: var(--text);
}

.users-search .search-input::placeholder {
color: var(--text-subtle);
}

.users-search .search-input:focus {
Expand Down
6 changes: 6 additions & 0 deletions src/components/database/DatabaseSidebar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,12 @@ function clearSearch() {
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: var(--text-sm);
background: var(--surface);
color: var(--text);
}

.search-input::placeholder {
color: var(--text-subtle);
}

.search-input:focus {
Expand Down
6 changes: 6 additions & 0 deletions src/components/database/QueryHistory.vue
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,12 @@ onMounted(() => {
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: var(--text-sm);
background: var(--surface);
color: var(--text);
}

.search-input::placeholder {
color: var(--text-subtle);
}

.search-input:focus {
Expand Down
6 changes: 6 additions & 0 deletions src/components/database/TableList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,12 @@ function formatNumber(num: number): string {
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: var(--text-sm);
background: var(--surface);
color: var(--text);
}

.search-input::placeholder {
color: var(--text-subtle);
}

.search-input:focus {
Expand Down
2 changes: 1 addition & 1 deletion src/composables/useDeploymentJob.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ describe("useDeploymentJob", () => {
await run("start", "app");
await flushPromises();

expect(start).toHaveBeenCalledWith("app");
expect(start).toHaveBeenCalledWith("app", undefined);
expect(getJob).toHaveBeenCalledWith("app", "job-1");
expect(state.isRunning).toBe(false);
expect(state.isSuccess).toBe(true);
Expand Down
16 changes: 8 additions & 8 deletions src/composables/useDeploymentJob.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { reactive, onScopeDispose } from "vue";
import { deploymentsApi, deploymentJobWsUrl, type DeploymentActionStatus } from "@/services/api";
import { deploymentsApi, deploymentJobWsUrl, type DeploymentActionStatus, type ActionOptions } from "@/services/api";

export type DeploymentOperation = "start" | "stop" | "restart" | "rebuild";

Expand Down Expand Up @@ -99,23 +99,23 @@ export function useDeploymentJob(onSettled?: (state: DeploymentJobState) => void
onSettled?.(state);
}

async function enqueue(operation: DeploymentOperation, name: string) {
async function enqueue(operation: DeploymentOperation, name: string, opts?: ActionOptions) {
switch (operation) {
case "start":
return deploymentsApi.start(name);
return deploymentsApi.start(name, opts);
case "stop":
return deploymentsApi.stop(name);
return deploymentsApi.stop(name, opts);
case "restart":
return deploymentsApi.restart(name);
return deploymentsApi.restart(name, opts);
case "rebuild":
return deploymentsApi.rebuild(name);
return deploymentsApi.rebuild(name, opts);
}
}

async function run(operation: DeploymentOperation, name: string) {
async function run(operation: DeploymentOperation, name: string, opts?: ActionOptions) {
begin(operation, name);
try {
const res = await enqueue(operation, name);
const res = await enqueue(operation, name, opts);
attach(res.data.job_id);
} catch (e: any) {
const activeId = e?.response?.data?.active_job_id;
Expand Down
2 changes: 1 addition & 1 deletion src/composables/useServiceJobs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ describe("useServiceJobs", () => {
await run("web", "rebuild");
await flushPromises();

expect(serviceActionJob).toHaveBeenCalledWith("app", "web", "rebuild");
expect(serviceActionJob).toHaveBeenCalledWith("app", "web", "rebuild", undefined);
expect(getJob).toHaveBeenCalledWith("app", "job-1");
expect(states.web.action).toBe("rebuild");
expect(states.web.isRunning).toBe(false);
Expand Down
6 changes: 3 additions & 3 deletions src/composables/useServiceJobs.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { reactive, onScopeDispose } from "vue";
import { deploymentsApi, deploymentJobWsUrl, type DeploymentActionStatus } from "@/services/api";
import { deploymentsApi, deploymentJobWsUrl, type DeploymentActionStatus, type ActionOptions } from "@/services/api";

export type ServiceAction = "start" | "stop" | "restart" | "rebuild" | "pull";

Expand Down Expand Up @@ -61,7 +61,7 @@ export function useServiceJobs(getDeployment: () => string, onSettled?: (s: Serv
onSettled?.(s);
}

async function run(service: string, action: ServiceAction) {
async function run(service: string, action: ServiceAction, opts?: ActionOptions) {
const name = getDeployment();
teardown(service);
controllers[service] = { socket: null, timer: null, settled: false };
Expand All @@ -72,7 +72,7 @@ export function useServiceJobs(getDeployment: () => string, onSettled?: (s: Serv
s.isSuccess = null;

try {
const res = await deploymentsApi.serviceActionJob(name, service, action);
const res = await deploymentsApi.serviceActionJob(name, service, action, opts);
openStream(service, res.data.job_id);
} catch (e: any) {
const activeId = e?.response?.data?.active_job_id;
Expand Down
23 changes: 17 additions & 6 deletions src/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ apiClient.interceptors.response.use(
export interface ServiceMetadata {
name: string;
type: string;
primary_service?: string;
networking: {
expose: boolean;
domain: string;
Expand Down Expand Up @@ -94,6 +95,14 @@ const withPlanQuery = (url: string, opts?: PlanOpts) => {

export type DeploymentActionStatus = "pending" | "running" | "succeeded" | "failed";

// ActionOptions are effective-apply flags so updated env vars and images take effect
// instead of a plain start/restart reusing cached config and images.
export interface ActionOptions {
force_recreate?: boolean;
no_cache?: boolean;
fresh_pull?: boolean;
}

export interface ActionJobResponse {
job_id: string;
deployment: string;
Expand Down Expand Up @@ -159,14 +168,16 @@ export const deploymentsApi = {
apiClient.post<{ message: string; name: string; service: string; output: string }>(
withPlanQuery(`/deployments/${name}/services/${service}/${action}`, opts),
),
start: (name: string) => apiClient.post<ActionJobResponse>(`/deployments/${name}/start`),
stop: (name: string) => apiClient.post<ActionJobResponse>(`/deployments/${name}/stop`),
restart: (name: string) => apiClient.post<ActionJobResponse>(`/deployments/${name}/restart`),
rebuild: (name: string) => apiClient.post<ActionJobResponse>(`/deployments/${name}/rebuild`),
start: (name: string, opts?: ActionOptions) => apiClient.post<ActionJobResponse>(`/deployments/${name}/start`, opts),
stop: (name: string, opts?: ActionOptions) => apiClient.post<ActionJobResponse>(`/deployments/${name}/stop`, opts),
restart: (name: string, opts?: ActionOptions) =>
apiClient.post<ActionJobResponse>(`/deployments/${name}/restart`, opts),
rebuild: (name: string, opts?: ActionOptions) =>
apiClient.post<ActionJobResponse>(`/deployments/${name}/rebuild`, opts),
getJob: (name: string, jobId: string) => apiClient.get<DeploymentJob>(`/deployments/${name}/jobs/${jobId}`),
getActiveJob: (name: string) => apiClient.get<DeploymentJob>(`/deployments/${name}/jobs/active`),
serviceActionJob: (name: string, service: string, action: string) =>
apiClient.post<ActionJobResponse>(`/deployments/${name}/services/${service}/job`, { action }),
serviceActionJob: (name: string, service: string, action: string, opts?: ActionOptions) =>
apiClient.post<ActionJobResponse>(`/deployments/${name}/services/${service}/job`, { action, ...opts }),
pullImage: (name: string, onlyLatest: boolean = false) =>
apiClient.post<{ message: string; name: string; output: string }>(`/deployments/${name}/pull`, {
only_latest: onlyLatest,
Expand Down
49 changes: 49 additions & 0 deletions src/services/marketplace.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { apiClient } from "./api";

export interface MarketplaceCategory {
slug: string;
name: string;
description: string | null;
icon: string | null;
color: string | null;
templates_count?: number;
}

export interface MarketplaceTemplate {
slug: string;
name: string;
description: string;
icon: string | null;
logo: string | null;
category?: MarketplaceCategory;
source_type: string;
latest_version: string | null;
downloads_count: number;
stars_count: number;
is_verified: boolean;
is_featured: boolean;
is_official: boolean;
updated_at: string;
}

// The download endpoint returns a bare agent-format payload (no data wrapper).
export interface AgentTemplatePayload {
id: string;
name: string;
description: string;
content: string;
version: string;
}

interface Paginated<T> {
data: T[];
meta: { current_page: number; last_page: number; per_page: number; total: number };
}

export const marketplaceApi = {
templates: (params?: { category?: string; featured?: boolean; per_page?: number; page?: number }) =>
apiClient.get<Paginated<MarketplaceTemplate>>("/marketplace/templates", { params }),
search: (q: string) => apiClient.get<Paginated<MarketplaceTemplate>>("/marketplace/search", { params: { q } }),
categories: () => apiClient.get<{ data: MarketplaceCategory[] }>("/marketplace/categories"),
download: (slug: string) => apiClient.get<AgentTemplatePayload>(`/marketplace/templates/${slug}/download`),
};
1 change: 1 addition & 0 deletions src/stores/plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export interface Plugin {
category: string;
enabled: boolean;
capabilities?: string[];
config_schema?: Record<string, unknown>;
widget?: {
enabled: boolean;
position: string;
Expand Down
2 changes: 2 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,14 @@ export interface Service {
health?: string;
ports?: string[];
networks?: string[];
is_primary?: boolean;
created_at: string;
}

export interface ServiceMetadata {
name: string;
type: string;
primary_service?: string;
networking: NetworkingConfig;
ssl: SSLConfig;
healthcheck: HealthCheckConfig;
Expand Down
70 changes: 67 additions & 3 deletions src/views/DeploymentDetailView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,10 @@
>
<div class="service-header">
<span class="service-name">{{ service.name }}</span>
<span v-if="service.is_primary" class="service-primary-badge" title="Primary service">
<Icon name="star" :size="12" />
Primary
</span>
<span class="service-status" :class="service.status">{{ service.status }}</span>
</div>
<div class="service-details">
Expand All @@ -343,6 +347,16 @@
</span>
</div>
<div class="service-actions">
<button
v-if="services.length > 1"
class="action-btn"
:class="{ 'action-btn--primary': service.is_primary }"
:title="service.is_primary ? 'Primary service (click to auto-detect)' : 'Make primary service'"
:disabled="savingPrimaryService"
@click="togglePrimaryService(service)"
>
<Icon :name="service.is_primary ? 'star' : 'star-off'" :size="14" />
</button>
<button class="action-btn" title="Resources" @click="openServiceResources(service)">
<i class="pi pi-sliders-h" />
</button>
Expand Down Expand Up @@ -1438,6 +1452,20 @@
changes. A simple restart does not use newly pulled images.
</span>
</div>
<div class="rebuild-options">
<label class="rebuild-option">
<input v-model="rebuildOptions.force_recreate" type="checkbox" />
<span>Force recreate containers <small>(apply updated environment variables)</small></span>
</label>
<label class="rebuild-option">
<input v-model="rebuildOptions.no_cache" type="checkbox" />
<span>Rebuild without cache <small>(ignore the build cache)</small></span>
</label>
<label class="rebuild-option">
<input v-model="rebuildOptions.fresh_pull" type="checkbox" />
<span>Pull fresh images <small>(do not use locally cached images)</small></span>
</label>
</div>
<div v-if="services.length > 1" class="rebuild-services">
<span class="rebuild-services-label">Or rebuild a single service</span>
<div v-for="service in services" :key="service.name" class="rebuild-service-row">
Expand Down Expand Up @@ -1773,6 +1801,7 @@ import {
securityApi,
credentialsApi,
type EnvVar,
type ActionOptions,
} from "@/services/api";
import { useNotificationsStore } from "@/stores/notifications";
import { useAuthStore } from "@/stores/auth";
Expand Down Expand Up @@ -2496,9 +2525,9 @@ const fetchStats = async () => {
}
};

const handleOperation = async (operation: string, onlyLatest: boolean = false) => {
const handleOperation = async (operation: string, onlyLatest: boolean = false, opts?: ActionOptions) => {
if (operation !== "pull") {
deploymentJob.run(operation as DeploymentOperation, route.params.name as string);
deploymentJob.run(operation as DeploymentOperation, route.params.name as string, opts);
return;
}

Expand Down Expand Up @@ -2543,9 +2572,11 @@ const executePull = async (onlyLatest: boolean) => {
await handleOperation("pull", onlyLatest);
};

const rebuildOptions = reactive<ActionOptions>({ force_recreate: true, no_cache: false, fresh_pull: false });

const executeRebuild = async () => {
showRebuildModal.value = false;
await handleOperation("rebuild");
await handleOperation("rebuild", false, { ...rebuildOptions });
};

const executeAction = async (action: { id: string; name: string }) => {
Expand Down Expand Up @@ -2934,6 +2965,23 @@ const openDomainSettings = () => {
showDomainSettingsModal.value = true;
};

const savingPrimaryService = ref(false);
const togglePrimaryService = (service: { name: string; is_primary?: boolean }) =>
setPrimaryService(service.is_primary ? "" : service.name);

const setPrimaryService = async (value: string) => {
savingPrimaryService.value = true;
try {
await deploymentsApi.updateMetadata(route.params.name as string, { primary_service: value });
notifications.success("Saved", value ? `Primary service set to ${value}` : "Primary service reset to auto-detect");
await fetchDeployment();
} catch (err: any) {
notifications.error("Save Failed", err.response?.data?.error || err.message);
} finally {
savingPrimaryService.value = false;
}
};

const saveDomainSettings = async () => {
savingDomainSettings.value = true;
try {
Expand Down Expand Up @@ -3573,6 +3621,22 @@ onUnmounted(() => {
font-weight: var(--font-medium);
}

.service-primary-badge {
display: inline-flex;
align-items: center;
gap: var(--space-1);
font-size: var(--text-xs);
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-full);
font-weight: var(--font-medium);
color: var(--primary);
background: var(--primary-soft, rgba(99, 102, 241, 0.12));
}

.action-btn--primary {
color: var(--primary);
}

/* Databases List */
.databases-list {
display: flex;
Expand Down
Loading
Loading