Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
2f45834
feat(chat): implement chat service and UI components
maksymhryzodub-prog Jul 20, 2026
1fbdc9e
refactor(bridle): adopt domain/data layered architecture
maksymhryzodub-prog Jul 20, 2026
7a16915
refactor(agent,auth): adopt domain/data layered architecture
maksymhryzodub-prog Jul 20, 2026
37661ce
feat(error): implement error handling architecture with domain and co…
maksymhryzodub-prog Jul 21, 2026
8dac2aa
feat(auth): add common authentication form and provider components
maksymhryzodub-prog Jul 21, 2026
201dbbe
refactor(admin/chat): adopt domain/data layered architecture
maksymhryzodub-prog Jul 21, 2026
b17c688
feat(error): wire admin chat into the #error system; fix ErrorMapper …
maksymhryzodub-prog Jul 21, 2026
4139ef1
refactor(admin/agent): adopt domain/data layered architecture
maksymhryzodub-prog Jul 21, 2026
a9fe423
refactor(admin/agentChannel): adopt domain/data layered architecture
maksymhryzodub-prog Jul 21, 2026
9f5b2bd
refactor(admin/agentSecret): adopt domain/data layered architecture
maksymhryzodub-prog Jul 21, 2026
6ffb57e
refactor(admin/agentFile): adopt domain/data layered architecture
maksymhryzodub-prog Jul 21, 2026
8aa2c43
refactor(admin/template): adopt domain/data layered architecture
maksymhryzodub-prog Jul 21, 2026
be9ccfd
refactor(admin/templateFile): adopt domain/data layered architecture
maksymhryzodub-prog Jul 21, 2026
c4d3e1f
refactor(admin/templateInstall): adopt domain/data layered architecture
maksymhryzodub-prog Jul 21, 2026
bf24eee
refactor(admin/llm): adopt domain/data layered architecture
maksymhryzodub-prog Jul 21, 2026
e6144b6
refactor(admin/mcpServer): adopt domain/data layered architecture
maksymhryzodub-prog Jul 21, 2026
98711be
refactor(admin/sessions): adopt domain/data layered architecture
maksymhryzodub-prog Jul 21, 2026
3f7d024
refactor(admin/setting): adopt domain/data layered architecture
maksymhryzodub-prog Jul 21, 2026
4a221a5
refactor(admin/skill): adopt domain/data layered architecture
maksymhryzodub-prog Jul 21, 2026
a38a6fa
refactor(admin/usage): adopt domain/data layered architecture
maksymhryzodub-prog Jul 21, 2026
6e07231
refactor(admin/user): adopt domain/data layered architecture
maksymhryzodub-prog Jul 21, 2026
17ec82b
refactor(admin/apiKey): adopt domain/data layered architecture
maksymhryzodub-prog Jul 21, 2026
1051a83
refactor(admin/auth): adopt domain/data layered architecture
maksymhryzodub-prog Jul 21, 2026
7051b19
refactor(admin/rancher): adopt domain/data layered architecture
maksymhryzodub-prog Jul 21, 2026
02a9368
refactor(admin/paddock): adopt domain/data layered architecture
maksymhryzodub-prog Jul 21, 2026
c863729
refactor(admin/reins): adopt domain/data layered architecture
maksymhryzodub-prog Jul 21, 2026
3b50191
refactor(admin/agentSecret): remove unused imports in Provider.vue
maksymhryzodub-prog Jul 21, 2026
e5f6de8
fix(admin/auth): surface login errors + add password visibility toggle
maksymhryzodub-prog Jul 21, 2026
c285522
fix(admin/api): kick out to /login on a session 401
maksymhryzodub-prog Jul 21, 2026
0e86571
refactor(admin/auth): remove unused imports in Form.vue
maksymhryzodub-prog Jul 21, 2026
5a67c83
fix(app/api): kick out to /login on a session 401
maksymhryzodub-prog Jul 21, 2026
ea3344b
refactor(app/auth): store the access token in a cookie, not localStorage
maksymhryzodub-prog Jul 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
185 changes: 185 additions & 0 deletions admin/slices/agent/agent/data/agent.gateway.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import { AgentsService, LogsService } from '#api/data';
import { client } from '#api/data/repositories/api/client.gen';
import { BaseGateway } from '#common/data/BaseGateway';
import { unwrapEnvelope } from '#common/data/unwrapEnvelope';
import { IAgentGateway } from '../domain/agent.gateway';
import type {
IAgentData,
IAgentEnvVar,
IAgentMetrics,
ICreateAgentData,
IUpdateAgentData,
} from '../domain/agent.types';
import { AgentMapper } from './agent.mapper';

interface HeyApiResult {
data?: unknown;
error?: unknown;
response?: { status?: number };
}

/**
* Hey API's axios client never throws by default — it returns
* `{ data, error, response }`, and on a network-level failure (no HTTP
* response: CORS rejection, API down) it sets `error` to an empty `{}` that is
* truthy but has no `.message`. Turn every failure mode into a meaningful Error
* and return the unwrapped payload on success.
*/
function unwrapOrThrow(res: HeyApiResult, action: string): unknown {
const status = res.response?.status;
const err = res.error;
if (err !== undefined && err !== null) {
const message = (err as { message?: string }).message;
if (message) throw new Error(`${action} failed: ${message}`);
if (status && status >= 400) throw new Error(`${action} failed: HTTP ${status}`);
throw new Error(
`${action} failed: could not reach the API. Check that the API is ` +
"running and that this app's origin is listed in CORS_ORIGIN.",
);
}
const payload = unwrapEnvelope(res.data);
if (payload === null) throw new Error(`${action} failed: the API returned no data.`);
return payload;
}

function readAccessToken(): string | null {
if (typeof document === 'undefined') return null;
const m = document.cookie.match(/(?:^|;\s*)access_token=([^;]+)/);
return m ? decodeURIComponent(m[1]) : null;
}

export class AgentGateway extends BaseGateway implements IAgentGateway {
private mapper = new AgentMapper();

findAll(): Promise<IAgentData[]> {
return this.execute(async () => {
const res = await AgentsService.agentControllerFindAll();
return this.mapper.toList(unwrapEnvelope(res.data));
});
}

findById(id: string): Promise<IAgentData | null> {
return this.execute(async () => {
const res = await AgentsService.agentControllerFindById({ path: { id } });
return this.mapper.toEntity(unwrapEnvelope(res.data));
});
}

findAdmin(): Promise<IAgentData | null> {
return this.execute(async () => {
const res = await AgentsService.agentControllerFindAdmin();
return this.mapper.toEntity(unwrapEnvelope(res.data));
});
}

create(input: ICreateAgentData): Promise<IAgentData> {
return this.execute(async () => {
const res = await AgentsService.agentControllerCreate({
body: this.mapper.toCreateDto(input),
});
return this.entityOrThrow(unwrapOrThrow(res, 'Agent create'), 'Agent create');
});
}

update(id: string, input: IUpdateAgentData): Promise<IAgentData> {
return this.execute(async () => {
const res = await AgentsService.agentControllerUpdate({
path: { id },
body: this.mapper.toUpdateDto(input),
});
return this.entityOrThrow(unwrapOrThrow(res, 'Agent update'), 'Agent update');
});
}

restart(id: string): Promise<IAgentData> {
return this.execute(async () => {
const res = await AgentsService.agentControllerRestart({ path: { id } });
return this.entityOrThrow(unwrapOrThrow(res, 'Restart'), 'Restart');
});
}

// Raw axios: the generated SDK hasn't been regenerated for stop/start/env/
// metrics yet. `client.instance` carries the Bearer via the api interceptor.
stop(id: string): Promise<IAgentData> {
return this.execute(async () => {
const res = await client.instance.post(`/agents/${id}/stop`);
return this.entityOrThrow(unwrapEnvelope(res.data), 'Stop');
});
}

start(id: string): Promise<IAgentData> {
return this.execute(async () => {
const res = await client.instance.post(`/agents/${id}/start`);
return this.entityOrThrow(unwrapEnvelope(res.data), 'Start');
});
}

// Raw fetch: the OpenAPI spec doesn't expose `wipeS3` as a typed query param.
// Re-attach the Bearer from the access_token cookie ourselves (the same job
// the SDK's axios interceptor does).
remove(id: string, wipeS3: boolean): Promise<void> {
return this.execute(async () => {
const runtime = useRuntimeConfig();
const url = new URL(`${runtime.public.apiUrl}/agents/${id}`);
if (wipeS3) url.searchParams.set('wipeS3', 'true');
const headers: Record<string, string> = {};
const token = readAccessToken();
if (token) headers.Authorization = `Bearer ${token}`;
const res = await fetch(url.toString(), {
method: 'DELETE',
credentials: 'include',
headers,
});
if (!res.ok) {
throw new Error(`Delete failed: ${res.status} ${res.statusText}`);
}
});
}

promoteAdmin(id: string): Promise<IAgentData> {
return this.execute(async () => {
const res = await AgentsService.agentControllerPromoteAdmin({ path: { id } });
return this.entityOrThrow(unwrapEnvelope(res.data), 'Promote');
});
}

demoteAdmin(id: string): Promise<IAgentData> {
return this.execute(async () => {
const res = await AgentsService.agentControllerDemoteAdmin({ path: { id } });
return this.entityOrThrow(unwrapEnvelope(res.data), 'Demote');
});
}

logs(id: string): Promise<string> {
return this.execute(async () => {
// `tail` is a required query param (last N lines); the old store omitted
// it (a latent type error) and relied on the server default.
const res = await LogsService.logControllerGetLogs({
path: { agentId: id },
query: { tail: '1000' },
});
const payload = unwrapEnvelope<{ logs?: string }>(res.data);
return typeof payload?.logs === 'string' ? payload.logs : '';
});
}

env(id: string): Promise<IAgentEnvVar[]> {
return this.execute(async () => {
const res = await client.instance.get(`/agents/${id}/env`);
return this.mapper.toEnvVars(unwrapEnvelope(res.data));
});
}

metrics(id: string): Promise<IAgentMetrics | null> {
return this.execute(async () => {
const res = await client.instance.get(`/agents/${id}/metrics`);
return this.mapper.toMetrics(unwrapEnvelope(res.data));
});
}

private entityOrThrow(payload: unknown, action: string): IAgentData {
const entity = this.mapper.toEntity(payload);
if (!entity) throw new Error(`${action} returned no agent data`);
return entity;
}
}
129 changes: 129 additions & 0 deletions admin/slices/agent/agent/data/agent.mapper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import type {
CreateAgentDto,
UpdateAgentDto,
} from '#api/data/repositories/api/types.gen';
import type {
AgentStatusTypes,
IAgentData,
IAgentEnvVar,
IAgentMetrics,
IAgentResources,
ICreateAgentData,
IUpdateAgentData,
} from '../domain/agent.types';

const KNOWN_STATUSES = new Set<AgentStatusTypes>([
'pending',
'deploying',
'running',
'failed',
'stopped',
]);

function num(value: unknown): number {
return typeof value === 'number' ? value : 0;
}

function strList(value: unknown): string[] {
return Array.isArray(value)
? value.filter((x): x is string => typeof x === 'string')
: [];
}

/**
* Maps the agents API onto domain shapes. Endpoints are typed as `unknown`
* (no response DTO), so `toEntity` reads defensively and drops anything without
* a string `id`. Input payloads are structurally the wire DTOs except
* `llmCredentialId`, which the domain models as `string | null` but the DTO as
* `string | undefined` — coerce null → undefined so "no credential" round-trips.
*/
export class AgentMapper {
toEntity(raw: unknown): IAgentData | null {
if (!raw || typeof raw !== 'object') return null;
const o = raw as Record<string, unknown>;
if (typeof o.id !== 'string') return null;
return {
id: o.id,
name: typeof o.name === 'string' ? o.name : '',
templateId: typeof o.templateId === 'string' ? o.templateId : '',
llmCredentialId:
typeof o.llmCredentialId === 'string' ? o.llmCredentialId : null,
status: this.toStatus(o.status),
workflowId: typeof o.workflowId === 'string' ? o.workflowId : null,
config:
o.config && typeof o.config === 'object'
? (o.config as Record<string, unknown>)
: {},
resources: this.toResources(o.resources),
isPublic: o.isPublic === true,
allowedOrigins: strList(o.allowedOrigins),
knowledgeIds: strList(o.knowledgeIds),
isAdmin: o.isAdmin === true,
debugEnabled: o.debugEnabled === true,
createdAt: typeof o.createdAt === 'string' ? o.createdAt : '',
updatedAt: typeof o.updatedAt === 'string' ? o.updatedAt : '',
};
}

toList(raw: unknown): IAgentData[] {
if (!Array.isArray(raw)) return [];
return raw
.map((item) => this.toEntity(item))
.filter((a): a is IAgentData => a !== null);
}

toMetrics(raw: unknown): IAgentMetrics | null {
if (!raw || typeof raw !== 'object') return null;
const o = raw as Record<string, unknown>;
const pod = (o.pod ?? {}) as Record<string, unknown>;
const node = (o.node ?? {}) as Record<string, unknown>;
if (!o.pod || !o.node) return null;
return {
pod: {
cpuMilli: num(pod.cpuMilli),
memBytes: num(pod.memBytes),
cpuLimitMilli: num(pod.cpuLimitMilli),
memLimitBytes: num(pod.memLimitBytes),
},
node: {
name: typeof node.name === 'string' ? node.name : '',
diskAvailBytes: num(node.diskAvailBytes),
diskCapacityBytes: num(node.diskCapacityBytes),
},
};
}

toEnvVars(raw: unknown): IAgentEnvVar[] {
if (!Array.isArray(raw)) return [];
return raw
.filter((e): e is Record<string, unknown> => !!e && typeof e === 'object')
.map((e) => ({
name: typeof e.name === 'string' ? e.name : '',
value: typeof e.value === 'string' ? e.value : '',
}));
}

toCreateDto(input: ICreateAgentData): CreateAgentDto {
return { ...input, llmCredentialId: input.llmCredentialId ?? undefined };
}

toUpdateDto(input: IUpdateAgentData): UpdateAgentDto {
return { ...input, llmCredentialId: input.llmCredentialId ?? undefined };
}

// ── internals ──────────────────────────────────────────────────────────

private toStatus(raw: unknown): AgentStatusTypes {
return typeof raw === 'string' && KNOWN_STATUSES.has(raw as AgentStatusTypes)
? (raw as AgentStatusTypes)
: 'pending';
}

private toResources(raw: unknown): IAgentResources {
const r = raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {};
return {
cpu: typeof r.cpu === 'string' ? r.cpu : '',
memory: typeof r.memory === 'string' ? r.memory : '',
};
}
}
57 changes: 57 additions & 0 deletions admin/slices/agent/agent/data/agentStatus.gateway.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { IAgentStatusGateway } from '../domain/agentStatus.gateway';
import type { IAgentStatusCallbacks } from '../domain/agentStatus.types';
import { AgentStatusMapper } from './agentStatus.mapper';

/**
* Owns the `/agents/status/stream` EventSource. Opens the stream on the first
* `subscribe`, forwards decoded frames + connection-state changes to the store's
* callbacks, and closes on `unsubscribe`. The store does the consumer
* ref-counting, so subscribe/unsubscribe are called once per open/close cycle.
*/
export class AgentStatusGateway implements IAgentStatusGateway {
private mapper = new AgentStatusMapper();
private source: EventSource | null = null;
private callbacks: IAgentStatusCallbacks | null = null;

constructor(private getBaseUrl: () => string) {}

subscribe(callbacks: IAgentStatusCallbacks): void {
if (!import.meta.client) return;
this.callbacks = callbacks;
if (this.source) return;

callbacks.onConnectionStateChange('connecting');
const baseUrl = this.getBaseUrl().replace(/\/$/, '');
this.source = new EventSource(`${baseUrl}/agents/status/stream`);

this.source.onopen = () => {
this.callbacks?.onConnectionStateChange('connected');
};

this.source.onmessage = (raw: MessageEvent) => {
let parsed: unknown;
try {
parsed = JSON.parse(raw.data);
} catch (err) {
// malformed payload — keep the connection but skip the message
console.warn('[agentStatus] failed to parse SSE payload', err);
return;
}
const message = this.mapper.toStreamMessage(parsed);
if (message) this.callbacks?.onMessage(message);
};

this.source.onerror = () => {
// EventSource handles reconnection on its own; reflect intermediate state
this.callbacks?.onConnectionStateChange('disconnected');
};
}

unsubscribe(): void {
if (this.source) {
this.source.close();
this.source = null;
}
this.callbacks = null;
}
}
Loading
Loading