feat(ui): Live marketplace, app detail drawer, and deployment controls by nfebe · Pull Request #77 · flatrun/ui · GitHub
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
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
Loading
Loading