Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ public ToolResultBlock executeSql(
() -> {
Datasource datasource =
dataSourceService.getDatasourceForSession(ctx.sessionId());
dataSourceService.bindSessionDatasource(ctx.sessionId(), datasource.getId());
QueryResult result = sqlExecutor.execute(datasource, sql);
return ToolResultBlock.text(formatResult(result));
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/*
* Copyright (C) 2026 github.com/MaloneTalk
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
* limitations under the License.
*/
package io.github.malonetalk.controller;

import io.github.malonetalk.common.Result;
import io.github.malonetalk.dto.DashboardDtos.DashboardCardCreateRequest;
import io.github.malonetalk.dto.DashboardDtos.DashboardCardRefreshResponse;
import io.github.malonetalk.dto.DashboardDtos.DashboardCardResponse;
import io.github.malonetalk.service.DashboardService;
import io.github.malonetalk.utils.RequestAssert;
import jakarta.validation.Valid;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class DashboardController {

private final DashboardService dashboardService;

@GetMapping("/dashboard-cards")
public Result<List<DashboardCardResponse>> listCards() {
return Result.success(dashboardService.listCards());
}

@PostMapping("/dashboard-cards")
public Result<DashboardCardResponse> createCard(
@Valid @RequestBody DashboardCardCreateRequest request) {
return Result.success(dashboardService.createCard(request));
}

@DeleteMapping("/dashboard-cards/{id}")
public Result<Void> deleteCard(@PathVariable Integer id) {
RequestAssert.requireNonNegative(id, "id must be non-negative.");
dashboardService.deleteCard(id);
return Result.success();
}

@PostMapping("/dashboard-cards/refresh")
public Result<Map<Integer, DashboardCardRefreshResponse>> refreshCards(
@RequestBody List<Integer> ids) {
return Result.success(dashboardService.refreshCards(ids));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/*
* Copyright (C) 2026 github.com/MaloneTalk
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
* limitations under the License.
*/
package io.github.malonetalk.dto;

import io.github.malonetalk.agent.datasource.QueryResult;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public final class DashboardDtos {

private DashboardDtos() {}

public record DashboardCardCreateRequest(
@NotBlank(message = "title cannot be blank.")
@Size(max = 255, message = "title length cannot exceed 255.")
String title,
@NotBlank(message = "sessionId cannot be blank.") String sessionId,
@NotBlank(message = "sqlText cannot be blank.") String sqlText,
@NotBlank(message = "chartType cannot be blank.") String chartType) {}

public record DashboardCardResponse(
Integer id, String title, Integer datasourceId, String sqlText, String chartType) {}

public record DashboardCardRefreshResponse(QueryResult result, String errorMessage) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/*
* Copyright (C) 2026 github.com/MaloneTalk
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
* limitations under the License.
*/
package io.github.malonetalk.entity;

import lombok.Data;

@Data
public class DashboardCard {

private Integer id;
private String title;
private Integer datasourceId;
private String sqlText;
private String chartType;
private Integer creatorId;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/*
* Copyright (C) 2026 github.com/MaloneTalk
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
* limitations under the License.
*/
package io.github.malonetalk.mapper;

import io.github.malonetalk.entity.DashboardCard;
import java.util.List;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;

@Mapper
public interface DashboardMapper {

int insertCard(DashboardCard card);

List<DashboardCard> selectCardsByCreator(@Param("creatorId") Integer creatorId);

List<DashboardCard> selectCardsByIdsAndCreator(
@Param("ids") List<Integer> ids, @Param("creatorId") Integer creatorId);

int deleteCard(@Param("id") Integer id, @Param("creatorId") Integer creatorId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
/*
* Copyright (C) 2026 github.com/MaloneTalk
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
* limitations under the License.
*/
package io.github.malonetalk.service;

import io.github.malonetalk.agent.SessionService;
import io.github.malonetalk.agent.datasource.QueryResult;
import io.github.malonetalk.agent.datasource.SqlExecutor;
import io.github.malonetalk.common.ErrorCode;
import io.github.malonetalk.common.UserContext;
import io.github.malonetalk.dto.DashboardDtos.DashboardCardCreateRequest;
import io.github.malonetalk.dto.DashboardDtos.DashboardCardRefreshResponse;
import io.github.malonetalk.dto.DashboardDtos.DashboardCardResponse;
import io.github.malonetalk.entity.DashboardCard;
import io.github.malonetalk.entity.Datasource;
import io.github.malonetalk.exception.BusinessException;
import io.github.malonetalk.mapper.DashboardMapper;
import io.github.malonetalk.utils.RequestAssert;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class DashboardService {

private static final int MAX_REFRESH_CARDS = 10;

private final DashboardMapper dashboardMapper;
private final DatasourceService datasourceService;
private final SessionService sessionService;
private final SqlExecutor sqlExecutor;

public List<DashboardCardResponse> listCards() {
return dashboardMapper.selectCardsByCreator(currentUserId()).stream()
.map(this::toCardResponse)
.toList();
}

@Transactional
public DashboardCardResponse createCard(DashboardCardCreateRequest request) {
UserContext user = UserContext.require();
sessionService.requireOwnership(user.scopedUserId(), request.sessionId());
Datasource datasource = datasourceService.getDatasourceForSession(request.sessionId());
String sql = sqlExecutor.validateSelectSql(request.sqlText());

DashboardCard card = new DashboardCard();
card.setTitle(RequestAssert.requireNotBlank(request.title(), "title cannot be blank."));
card.setDatasourceId(datasource.getId());
card.setSqlText(sql);
card.setChartType(normalizeChartType(request.chartType()));
card.setCreatorId(user.userId());
if (dashboardMapper.insertCard(card) <= 0) {
throw BusinessException.of(
ErrorCode.OPERATION_FAILED, "Failed to save dashboard card.");
}
return toCardResponse(card);
}

@Transactional
public void deleteCard(Integer id) {
if (dashboardMapper.deleteCard(id, currentUserId()) <= 0) {
throw BusinessException.of(
ErrorCode.RESOURCE_NOT_FOUND, "Dashboard card does not exist: id=" + id);
}
}

public Map<Integer, DashboardCardRefreshResponse> refreshCards(List<Integer> ids) {
RequestAssert.requireNotEmpty(ids, "card ids cannot be empty.");
ids.forEach(id -> RequestAssert.requireNonNegative(id, "id must be non-negative."));
List<Integer> cardIds = ids.stream().distinct().toList();
if (cardIds.size() > MAX_REFRESH_CARDS) {
throw BusinessException.of(
ErrorCode.BAD_REQUEST,
"Cannot refresh more than " + MAX_REFRESH_CARDS + " dashboard cards.");
}
List<DashboardCard> cards =
dashboardMapper.selectCardsByIdsAndCreator(cardIds, currentUserId());
if (cards.size() != cardIds.size()) {
throw BusinessException.of(
ErrorCode.RESOURCE_NOT_FOUND, "Dashboard card does not exist.");
}

Map<Integer, DashboardCardRefreshResponse> results = new LinkedHashMap<>();
for (DashboardCard card : cards) {
results.put(card.getId(), refreshCardSafely(card));
}
return results;
}

private DashboardCardRefreshResponse refreshCardSafely(DashboardCard card) {
try {
return new DashboardCardRefreshResponse(refreshCard(card), null);
} catch (BusinessException e) {
return new DashboardCardRefreshResponse(null, e.getMessage());
}
}

private QueryResult refreshCard(DashboardCard card) {
Datasource datasource = requireDatasource(card.getDatasourceId());
return sqlExecutor.execute(datasource, card.getSqlText());
}

private Datasource requireDatasource(Integer id) {
RequestAssert.requireNonNull(id, "datasourceId cannot be null.");
Datasource datasource = datasourceService.findById(id);
if (datasource == null) {
throw BusinessException.of(ErrorCode.RESOURCE_NOT_FOUND, "Datasource not found.");
}
return datasource;
}

private String normalizeChartType(String chartType) {
String normalized = RequestAssert.requireNotBlank(chartType, "chartType cannot be blank.");
normalized = normalized.toLowerCase();
return switch (normalized) {
case "table", "metric", "bar" -> normalized;
default ->
throw BusinessException.of(
ErrorCode.BAD_REQUEST, "chartType must be table, metric or bar.");
};
}

private Integer currentUserId() {
return UserContext.require().userId();
}

private DashboardCardResponse toCardResponse(DashboardCard card) {
return new DashboardCardResponse(
card.getId(),
card.getTitle(),
card.getDatasourceId(),
card.getSqlText(),
card.getChartType());
}
}
34 changes: 34 additions & 0 deletions data-agent-backend/src/main/resources/mapper/DashboardMapper.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="io.github.malonetalk.mapper.DashboardMapper">

<insert id="insertCard" parameterType="io.github.malonetalk.entity.DashboardCard"
useGeneratedKeys="true" keyProperty="id">
INSERT INTO dashboard_card (
title, datasource_id, sql_text, chart_type, creator_id
) VALUES (
#{title}, #{datasourceId}, #{sqlText}, #{chartType}, #{creatorId}
)
</insert>

<select id="selectCardsByCreator" resultType="io.github.malonetalk.entity.DashboardCard">
SELECT * FROM dashboard_card
WHERE creator_id = #{creatorId}
ORDER BY id DESC
</select>

<select id="selectCardsByIdsAndCreator" resultType="io.github.malonetalk.entity.DashboardCard">
SELECT * FROM dashboard_card
WHERE creator_id = #{creatorId}
AND id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</select>

<delete id="deleteCard">
DELETE FROM dashboard_card
WHERE id = #{id}
AND creator_id = #{creatorId}
</delete>
</mapper>
Loading
Loading