feat(ai-service): complete ai production readiness tasks
All checks were successful
Build and Release / Create Release (push) Has been skipped
Build and Release / Unit Tests (push) Successful in 6m49s
Build and Release / Integration Tests (PostgreSQL) (push) Successful in 7m6s
Build and Release / Lint (push) Successful in 7m15s
Build and Release / Build Binaries (amd64, windows, windows-latest) (push) Has been skipped
Build and Release / Build Binaries (amd64, darwin, macos) (push) Has been skipped
Build and Release / Build Binaries (amd64, linux, linux-latest) (push) Has been skipped
Build and Release / Build Binaries (arm64, darwin, macos) (push) Has been skipped
Build and Release / Build Binary (linux/arm64) (push) Has been skipped
All checks were successful
Build and Release / Create Release (push) Has been skipped
Build and Release / Unit Tests (push) Successful in 6m49s
Build and Release / Integration Tests (PostgreSQL) (push) Successful in 7m6s
Build and Release / Lint (push) Successful in 7m15s
Build and Release / Build Binaries (amd64, windows, windows-latest) (push) Has been skipped
Build and Release / Build Binaries (amd64, darwin, macos) (push) Has been skipped
Build and Release / Build Binaries (amd64, linux, linux-latest) (push) Has been skipped
Build and Release / Build Binaries (arm64, darwin, macos) (push) Has been skipped
Build and Release / Build Binary (linux/arm64) (push) Has been skipped
Implement critical production readiness features for AI integration: per-request provider config, admin dashboard, workflow inspection, and plugin framework foundation. Per-Request Provider Config: - Add ProviderConfig struct to all AI request types - Update queue to resolve provider/model/API key from cascade (repo > org > system) - Pass resolved config to AI sidecar on every request - Fixes multi-tenant issue where all orgs shared sidecar's hardcoded config Admin AI Dashboard: - Add /admin/ai page with sidecar health status - Display global operation stats (total, 24h, success/fail/escalated counts) - Show operations by tier, top 5 repos, token usage - Recent operations table with repo, operation, status, duration - Add GetGlobalOperationStats model method Workflow Inspection: - Add InspectWorkflow client method and types - Implement workflow-inspect queue handler - Add notifier trigger on workflow file push - Analyzes YAML for syntax errors, security issues, best practices - Returns structured issues with line numbers and suggested fixes Plugin Framework (Phase 5 Foundation): - Add external plugin config loading from app.ini - Define ExternalPlugin interface and manager - Add plugin.proto contract (Initialize, Shutdown, HealthCheck, OnEvent, HandleHTTP) - Implement health monitoring with auto-restart for managed plugins - Add event routing to subscribed plugins - HTTP proxy support for plugin-served routes This completes Tasks 1-4 from the production readiness plan and establishes the foundation for managed plugin lifecycle.
This commit is contained in:
@@ -110,3 +110,98 @@ func CountRecentOperations(ctx context.Context, repoID int64) (int64, error) {
|
||||
oneHourAgo := timeutil.TimeStampNow() - 3600
|
||||
return db.GetEngine(ctx).Where("repo_id = ? AND created_unix > ?", repoID, oneHourAgo).Count(new(OperationLog))
|
||||
}
|
||||
|
||||
// GlobalOperationStats holds aggregate AI operation statistics for admin dashboard
|
||||
type GlobalOperationStats struct {
|
||||
TotalOperations int64 `json:"total_operations"`
|
||||
Operations24h int64 `json:"operations_24h"`
|
||||
SuccessCount int64 `json:"success_count"`
|
||||
FailedCount int64 `json:"failed_count"`
|
||||
EscalatedCount int64 `json:"escalated_count"`
|
||||
PendingCount int64 `json:"pending_count"`
|
||||
CountByTier map[int]int64 `json:"count_by_tier"`
|
||||
TopRepos []RepoOpCount `json:"top_repos"`
|
||||
TotalInputTokens int64 `json:"total_input_tokens"`
|
||||
TotalOutputTokens int64 `json:"total_output_tokens"`
|
||||
}
|
||||
|
||||
// RepoOpCount holds a repo's operation count for the top-repos list
|
||||
type RepoOpCount struct {
|
||||
RepoID int64 `json:"repo_id"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
// GetGlobalOperationStats returns aggregate statistics across all repos for the admin dashboard
|
||||
func GetGlobalOperationStats(ctx context.Context) (*GlobalOperationStats, error) {
|
||||
e := db.GetEngine(ctx)
|
||||
stats := &GlobalOperationStats{
|
||||
CountByTier: make(map[int]int64),
|
||||
}
|
||||
|
||||
// Total operations
|
||||
total, err := e.Count(new(OperationLog))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stats.TotalOperations = total
|
||||
|
||||
// Operations in last 24 hours
|
||||
oneDayAgo := timeutil.TimeStampNow() - 86400
|
||||
stats.Operations24h, err = e.Where("created_unix > ?", oneDayAgo).Count(new(OperationLog))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Counts by status
|
||||
stats.SuccessCount, err = e.Where("status = ?", OperationStatusSuccess).Count(new(OperationLog))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stats.FailedCount, err = e.Where("status = ?", OperationStatusFailed).Count(new(OperationLog))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stats.EscalatedCount, err = e.Where("status = ?", OperationStatusEscalated).Count(new(OperationLog))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stats.PendingCount, err = e.Where("status = ?", OperationStatusPending).Count(new(OperationLog))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Counts by tier
|
||||
type tierCount struct {
|
||||
Tier int `xorm:"tier"`
|
||||
Count int64 `xorm:"count"`
|
||||
}
|
||||
var tierCounts []tierCount
|
||||
if err := e.Table("ai_operation_log").Select("tier, count(*) as count").GroupBy("tier").Find(&tierCounts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, tc := range tierCounts {
|
||||
stats.CountByTier[tc.Tier] = tc.Count
|
||||
}
|
||||
|
||||
// Top 5 repos by operation count
|
||||
var topRepos []RepoOpCount
|
||||
if err := e.Table("ai_operation_log").Select("repo_id, count(*) as count").
|
||||
GroupBy("repo_id").OrderBy("count DESC").Limit(5).Find(&topRepos); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stats.TopRepos = topRepos
|
||||
|
||||
// Total tokens
|
||||
type tokenSum struct {
|
||||
InputTokens int64 `xorm:"input_tokens"`
|
||||
OutputTokens int64 `xorm:"output_tokens"`
|
||||
}
|
||||
var ts tokenSum
|
||||
if _, err := e.Table("ai_operation_log").Select("COALESCE(SUM(input_tokens),0) as input_tokens, COALESCE(SUM(output_tokens),0) as output_tokens").Get(&ts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stats.TotalInputTokens = ts.InputTokens
|
||||
stats.TotalOutputTokens = ts.OutputTokens
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
@@ -204,6 +204,15 @@ func (c *Client) GenerateIssueResponse(ctx context.Context, req *GenerateIssueRe
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// InspectWorkflow sends a workflow for AI inspection
|
||||
func (c *Client) InspectWorkflow(ctx context.Context, req *InspectWorkflowRequest) (*InspectWorkflowResponse, error) {
|
||||
resp := &InspectWorkflowResponse{}
|
||||
if err := c.doRequest(ctx, "POST", "/api/v1/workflows/inspect", req, resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// CheckHealth checks the health of the AI service
|
||||
func (c *Client) CheckHealth(ctx context.Context) (*HealthCheckResponse, error) {
|
||||
var resp HealthCheckResponse
|
||||
|
||||
@@ -3,6 +3,15 @@
|
||||
|
||||
package ai
|
||||
|
||||
// ProviderConfig contains per-request AI provider configuration.
|
||||
// When sent to the AI sidecar, it overrides the sidecar's default provider/model/key.
|
||||
// Fields left empty fall back to the sidecar's defaults.
|
||||
type ProviderConfig struct {
|
||||
Provider string `json:"provider,omitempty"` // "claude", "openai", "gemini"
|
||||
Model string `json:"model,omitempty"`
|
||||
APIKey string `json:"api_key,omitempty"`
|
||||
}
|
||||
|
||||
// FileDiff represents a file diff for code review
|
||||
type FileDiff struct {
|
||||
Path string `json:"path"`
|
||||
@@ -54,14 +63,15 @@ type SecurityAnalysis struct {
|
||||
|
||||
// ReviewPullRequestRequest is the request for reviewing a pull request
|
||||
type ReviewPullRequestRequest struct {
|
||||
RepoID int64 `json:"repo_id"`
|
||||
PullRequestID int64 `json:"pull_request_id"`
|
||||
BaseBranch string `json:"base_branch"`
|
||||
HeadBranch string `json:"head_branch"`
|
||||
Files []FileDiff `json:"files"`
|
||||
PRTitle string `json:"pr_title"`
|
||||
PRDescription string `json:"pr_description"`
|
||||
Options ReviewOptions `json:"options"`
|
||||
ProviderConfig *ProviderConfig `json:"provider_config,omitempty"`
|
||||
RepoID int64 `json:"repo_id"`
|
||||
PullRequestID int64 `json:"pull_request_id"`
|
||||
BaseBranch string `json:"base_branch"`
|
||||
HeadBranch string `json:"head_branch"`
|
||||
Files []FileDiff `json:"files"`
|
||||
PRTitle string `json:"pr_title"`
|
||||
PRDescription string `json:"pr_description"`
|
||||
Options ReviewOptions `json:"options"`
|
||||
}
|
||||
|
||||
// ReviewPullRequestResponse is the response from reviewing a pull request
|
||||
@@ -76,12 +86,13 @@ type ReviewPullRequestResponse struct {
|
||||
|
||||
// TriageIssueRequest is the request for triaging an issue
|
||||
type TriageIssueRequest struct {
|
||||
RepoID int64 `json:"repo_id"`
|
||||
IssueID int64 `json:"issue_id"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
ExistingLabels []string `json:"existing_labels"`
|
||||
AvailableLabels []string `json:"available_labels"`
|
||||
ProviderConfig *ProviderConfig `json:"provider_config,omitempty"`
|
||||
RepoID int64 `json:"repo_id"`
|
||||
IssueID int64 `json:"issue_id"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
ExistingLabels []string `json:"existing_labels"`
|
||||
AvailableLabels []string `json:"available_labels"`
|
||||
}
|
||||
|
||||
// TriageIssueResponse is the response from triaging an issue
|
||||
@@ -97,10 +108,11 @@ type TriageIssueResponse struct {
|
||||
|
||||
// SuggestLabelsRequest is the request for suggesting labels
|
||||
type SuggestLabelsRequest struct {
|
||||
RepoID int64 `json:"repo_id"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
AvailableLabels []string `json:"available_labels"`
|
||||
ProviderConfig *ProviderConfig `json:"provider_config,omitempty"`
|
||||
RepoID int64 `json:"repo_id"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
AvailableLabels []string `json:"available_labels"`
|
||||
}
|
||||
|
||||
// LabelSuggestion represents a suggested label
|
||||
@@ -117,12 +129,13 @@ type SuggestLabelsResponse struct {
|
||||
|
||||
// ExplainCodeRequest is the request for explaining code
|
||||
type ExplainCodeRequest struct {
|
||||
RepoID int64 `json:"repo_id"`
|
||||
FilePath string `json:"file_path"`
|
||||
Code string `json:"code"`
|
||||
StartLine int `json:"start_line"`
|
||||
EndLine int `json:"end_line"`
|
||||
Question string `json:"question,omitempty"`
|
||||
ProviderConfig *ProviderConfig `json:"provider_config,omitempty"`
|
||||
RepoID int64 `json:"repo_id"`
|
||||
FilePath string `json:"file_path"`
|
||||
Code string `json:"code"`
|
||||
StartLine int `json:"start_line"`
|
||||
EndLine int `json:"end_line"`
|
||||
Question string `json:"question,omitempty"`
|
||||
}
|
||||
|
||||
// CodeReference represents a reference to related documentation
|
||||
@@ -140,12 +153,13 @@ type ExplainCodeResponse struct {
|
||||
|
||||
// GenerateDocumentationRequest is the request for generating documentation
|
||||
type GenerateDocumentationRequest struct {
|
||||
RepoID int64 `json:"repo_id"`
|
||||
FilePath string `json:"file_path"`
|
||||
Code string `json:"code"`
|
||||
DocType string `json:"doc_type"` // function, class, module, api
|
||||
Language string `json:"language"`
|
||||
Style string `json:"style"` // jsdoc, docstring, xml, markdown
|
||||
ProviderConfig *ProviderConfig `json:"provider_config,omitempty"`
|
||||
RepoID int64 `json:"repo_id"`
|
||||
FilePath string `json:"file_path"`
|
||||
Code string `json:"code"`
|
||||
DocType string `json:"doc_type"` // function, class, module, api
|
||||
Language string `json:"language"`
|
||||
Style string `json:"style"` // jsdoc, docstring, xml, markdown
|
||||
}
|
||||
|
||||
// DocumentationSection represents a section of documentation
|
||||
@@ -162,9 +176,10 @@ type GenerateDocumentationResponse struct {
|
||||
|
||||
// GenerateCommitMessageRequest is the request for generating a commit message
|
||||
type GenerateCommitMessageRequest struct {
|
||||
RepoID int64 `json:"repo_id"`
|
||||
Files []FileDiff `json:"files"`
|
||||
Style string `json:"style"` // conventional, descriptive, brief
|
||||
ProviderConfig *ProviderConfig `json:"provider_config,omitempty"`
|
||||
RepoID int64 `json:"repo_id"`
|
||||
Files []FileDiff `json:"files"`
|
||||
Style string `json:"style"` // conventional, descriptive, brief
|
||||
}
|
||||
|
||||
// GenerateCommitMessageResponse is the response from generating a commit message
|
||||
@@ -175,9 +190,10 @@ type GenerateCommitMessageResponse struct {
|
||||
|
||||
// SummarizeChangesRequest is the request for summarizing changes
|
||||
type SummarizeChangesRequest struct {
|
||||
RepoID int64 `json:"repo_id"`
|
||||
Files []FileDiff `json:"files"`
|
||||
Context string `json:"context"`
|
||||
ProviderConfig *ProviderConfig `json:"provider_config,omitempty"`
|
||||
RepoID int64 `json:"repo_id"`
|
||||
Files []FileDiff `json:"files"`
|
||||
Context string `json:"context"`
|
||||
}
|
||||
|
||||
// SummarizeChangesResponse is the response from summarizing changes
|
||||
@@ -196,13 +212,14 @@ type IssueComment struct {
|
||||
|
||||
// GenerateIssueResponseRequest is the request for generating an AI response to an issue
|
||||
type GenerateIssueResponseRequest struct {
|
||||
RepoID int64 `json:"repo_id"`
|
||||
IssueID int64 `json:"issue_id"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
Comments []IssueComment `json:"comments,omitempty"`
|
||||
ResponseType string `json:"response_type,omitempty"` // clarification, solution, acknowledgment
|
||||
CustomInstructions string `json:"custom_instructions,omitempty"`
|
||||
ProviderConfig *ProviderConfig `json:"provider_config,omitempty"`
|
||||
RepoID int64 `json:"repo_id"`
|
||||
IssueID int64 `json:"issue_id"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
Comments []IssueComment `json:"comments,omitempty"`
|
||||
ResponseType string `json:"response_type,omitempty"` // clarification, solution, acknowledgment
|
||||
CustomInstructions string `json:"custom_instructions,omitempty"`
|
||||
}
|
||||
|
||||
// GenerateIssueResponseResponse is the response from generating an issue response
|
||||
@@ -214,6 +231,33 @@ type GenerateIssueResponseResponse struct {
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
}
|
||||
|
||||
// InspectWorkflowRequest is the request for inspecting a workflow file
|
||||
type InspectWorkflowRequest struct {
|
||||
ProviderConfig *ProviderConfig `json:"provider_config,omitempty"`
|
||||
RepoID int64 `json:"repo_id"`
|
||||
FilePath string `json:"file_path"`
|
||||
Content string `json:"content"`
|
||||
RunnerLabels []string `json:"runner_labels,omitempty"`
|
||||
}
|
||||
|
||||
// WorkflowIssue represents an issue found in a workflow file
|
||||
type WorkflowIssue struct {
|
||||
Line int `json:"line"`
|
||||
Severity string `json:"severity"` // "error", "warning", "info"
|
||||
Message string `json:"message"`
|
||||
Fix string `json:"fix,omitempty"`
|
||||
}
|
||||
|
||||
// InspectWorkflowResponse is the response from inspecting a workflow file
|
||||
type InspectWorkflowResponse struct {
|
||||
Valid bool `json:"valid"`
|
||||
Issues []WorkflowIssue `json:"issues"`
|
||||
Suggestions []string `json:"suggestions"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
}
|
||||
|
||||
// HealthCheckResponse is the response from a health check
|
||||
type HealthCheckResponse struct {
|
||||
Healthy bool `json:"healthy"`
|
||||
|
||||
96
modules/plugins/config.go
Normal file
96
modules/plugins/config.go
Normal file
@@ -0,0 +1,96 @@
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.gitcaddy.com/server/v3/modules/log"
|
||||
"code.gitcaddy.com/server/v3/modules/setting"
|
||||
)
|
||||
|
||||
// ExternalPluginConfig holds configuration for a single external plugin
|
||||
type ExternalPluginConfig struct {
|
||||
Name string
|
||||
Enabled bool
|
||||
// Managed mode: server launches the binary
|
||||
Binary string
|
||||
Args string
|
||||
// External mode: connect to already-running process
|
||||
Address string
|
||||
// Common
|
||||
SubscribedEvents []string
|
||||
HealthTimeout time.Duration
|
||||
}
|
||||
|
||||
// Config holds the global [plugins] configuration
|
||||
type Config struct {
|
||||
Enabled bool
|
||||
Path string
|
||||
HealthCheckInterval time.Duration
|
||||
ExternalPlugins map[string]*ExternalPluginConfig
|
||||
}
|
||||
|
||||
// LoadConfig loads plugin configuration from app.ini [plugins] and [plugins.*] sections
|
||||
func LoadConfig() *Config {
|
||||
cfg := &Config{
|
||||
ExternalPlugins: make(map[string]*ExternalPluginConfig),
|
||||
}
|
||||
|
||||
sec := setting.CfgProvider.Section("plugins")
|
||||
cfg.Enabled = sec.Key("ENABLED").MustBool(true)
|
||||
cfg.Path = sec.Key("PATH").MustString("data/plugins")
|
||||
cfg.HealthCheckInterval = sec.Key("HEALTH_CHECK_INTERVAL").MustDuration(30 * time.Second)
|
||||
|
||||
// Load [plugins.*] sections for external plugins
|
||||
for _, childSec := range sec.ChildSections() {
|
||||
name := strings.TrimPrefix(childSec.Name(), "plugins.")
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
pluginCfg := &ExternalPluginConfig{
|
||||
Name: name,
|
||||
Enabled: childSec.Key("ENABLED").MustBool(true),
|
||||
Binary: childSec.Key("BINARY").MustString(""),
|
||||
Args: childSec.Key("ARGS").MustString(""),
|
||||
Address: childSec.Key("ADDRESS").MustString(""),
|
||||
HealthTimeout: childSec.Key("HEALTH_TIMEOUT").MustDuration(5 * time.Second),
|
||||
}
|
||||
|
||||
// Parse subscribed events
|
||||
if eventsStr := childSec.Key("SUBSCRIBED_EVENTS").MustString(""); eventsStr != "" {
|
||||
pluginCfg.SubscribedEvents = splitAndTrim(eventsStr)
|
||||
}
|
||||
|
||||
// Validate: must have either binary or address
|
||||
if pluginCfg.Binary == "" && pluginCfg.Address == "" {
|
||||
log.Warn("Plugin %q has neither BINARY nor ADDRESS configured, skipping", name)
|
||||
continue
|
||||
}
|
||||
|
||||
cfg.ExternalPlugins[name] = pluginCfg
|
||||
log.Info("Loaded external plugin config: %s (managed=%v)", name, pluginCfg.IsManaged())
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
// IsManaged returns true if the server manages the plugin's lifecycle (has a binary)
|
||||
func (c *ExternalPluginConfig) IsManaged() bool {
|
||||
return c.Binary != ""
|
||||
}
|
||||
|
||||
// splitAndTrim splits a comma-separated string and trims whitespace
|
||||
func splitAndTrim(s string) []string {
|
||||
var result []string
|
||||
for part := range strings.SplitSeq(s, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part != "" {
|
||||
result = append(result, part)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
382
modules/plugins/external.go
Normal file
382
modules/plugins/external.go
Normal file
@@ -0,0 +1,382 @@
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"code.gitcaddy.com/server/v3/modules/graceful"
|
||||
"code.gitcaddy.com/server/v3/modules/json"
|
||||
"code.gitcaddy.com/server/v3/modules/log"
|
||||
pluginv1 "code.gitcaddy.com/server/v3/modules/plugins/pluginv1"
|
||||
)
|
||||
|
||||
// PluginStatus represents the status of an external plugin
|
||||
type PluginStatus string
|
||||
|
||||
const (
|
||||
PluginStatusStarting PluginStatus = "starting"
|
||||
PluginStatusOnline PluginStatus = "online"
|
||||
PluginStatusOffline PluginStatus = "offline"
|
||||
PluginStatusError PluginStatus = "error"
|
||||
)
|
||||
|
||||
// ManagedPlugin tracks the state of an external plugin
|
||||
type ManagedPlugin struct {
|
||||
config *ExternalPluginConfig
|
||||
process *os.Process
|
||||
status PluginStatus
|
||||
lastSeen time.Time
|
||||
manifest *pluginv1.PluginManifest
|
||||
failCount int
|
||||
httpClient *http.Client
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// ExternalPluginManager manages external plugins (both managed and external mode)
|
||||
type ExternalPluginManager struct {
|
||||
mu sync.RWMutex
|
||||
plugins map[string]*ManagedPlugin
|
||||
config *Config
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
var globalExternalManager *ExternalPluginManager
|
||||
|
||||
// GetExternalManager returns the global external plugin manager
|
||||
func GetExternalManager() *ExternalPluginManager {
|
||||
return globalExternalManager
|
||||
}
|
||||
|
||||
// NewExternalPluginManager creates a new external plugin manager
|
||||
func NewExternalPluginManager(config *Config) *ExternalPluginManager {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
m := &ExternalPluginManager{
|
||||
plugins: make(map[string]*ManagedPlugin),
|
||||
config: config,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
globalExternalManager = m
|
||||
return m
|
||||
}
|
||||
|
||||
// StartAll launches managed plugins and connects to external ones
|
||||
func (m *ExternalPluginManager) StartAll() error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
for name, cfg := range m.config.ExternalPlugins {
|
||||
if !cfg.Enabled {
|
||||
log.Info("External plugin %s is disabled, skipping", name)
|
||||
continue
|
||||
}
|
||||
|
||||
mp := &ManagedPlugin{
|
||||
config: cfg,
|
||||
status: PluginStatusStarting,
|
||||
httpClient: &http.Client{
|
||||
Timeout: cfg.HealthTimeout,
|
||||
},
|
||||
}
|
||||
m.plugins[name] = mp
|
||||
|
||||
if cfg.IsManaged() {
|
||||
if err := m.startManagedPlugin(mp); err != nil {
|
||||
log.Error("Failed to start managed plugin %s: %v", name, err)
|
||||
mp.status = PluginStatusError
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Try to initialize the plugin
|
||||
if err := m.initializePlugin(mp); err != nil {
|
||||
log.Error("Failed to initialize external plugin %s: %v", name, err)
|
||||
mp.status = PluginStatusError
|
||||
continue
|
||||
}
|
||||
|
||||
mp.status = PluginStatusOnline
|
||||
mp.lastSeen = time.Now()
|
||||
log.Info("External plugin %s is online (managed=%v)", name, cfg.IsManaged())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// StopAll gracefully shuts down all external plugins
|
||||
func (m *ExternalPluginManager) StopAll() {
|
||||
m.cancel()
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
for name, mp := range m.plugins {
|
||||
log.Info("Shutting down external plugin: %s", name)
|
||||
|
||||
// Send shutdown request
|
||||
m.shutdownPlugin(mp)
|
||||
|
||||
// Kill managed process
|
||||
if mp.process != nil {
|
||||
if err := mp.process.Signal(os.Interrupt); err != nil {
|
||||
log.Warn("Failed to send interrupt to plugin %s, killing: %v", name, err)
|
||||
_ = mp.process.Kill()
|
||||
}
|
||||
}
|
||||
|
||||
mp.status = PluginStatusOffline
|
||||
}
|
||||
}
|
||||
|
||||
// GetPlugin returns an external plugin by name
|
||||
func (m *ExternalPluginManager) GetPlugin(name string) *ManagedPlugin {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.plugins[name]
|
||||
}
|
||||
|
||||
// AllPlugins returns all external plugins
|
||||
func (m *ExternalPluginManager) AllPlugins() map[string]*ManagedPlugin {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
result := make(map[string]*ManagedPlugin, len(m.plugins))
|
||||
maps.Copy(result, m.plugins)
|
||||
return result
|
||||
}
|
||||
|
||||
// OnEvent dispatches an event to all interested plugins (fire-and-forget with timeout)
|
||||
func (m *ExternalPluginManager) OnEvent(event *pluginv1.PluginEvent) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
for name, mp := range m.plugins {
|
||||
mp.mu.RLock()
|
||||
if mp.status != PluginStatusOnline || mp.manifest == nil {
|
||||
mp.mu.RUnlock()
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this plugin is subscribed to this event
|
||||
subscribed := false
|
||||
for _, e := range mp.manifest.SubscribedEvents {
|
||||
if e == event.EventType || e == "*" {
|
||||
subscribed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
mp.mu.RUnlock()
|
||||
|
||||
if !subscribed {
|
||||
continue
|
||||
}
|
||||
|
||||
// Dispatch in background with timeout
|
||||
go func(pluginName string, p *ManagedPlugin) {
|
||||
ctx, cancel := context.WithTimeout(m.ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := m.callOnEvent(ctx, p, event); err != nil {
|
||||
log.Error("Failed to dispatch event %s to plugin %s: %v", event.EventType, pluginName, err)
|
||||
}
|
||||
}(name, mp)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleHTTP proxies an HTTP request to a plugin that declares the matching route
|
||||
func (m *ExternalPluginManager) HandleHTTP(method, path string, headers map[string]string, body []byte) (*pluginv1.HTTPResponse, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
for name, mp := range m.plugins {
|
||||
mp.mu.RLock()
|
||||
if mp.status != PluginStatusOnline || mp.manifest == nil {
|
||||
mp.mu.RUnlock()
|
||||
continue
|
||||
}
|
||||
|
||||
for _, route := range mp.manifest.Routes {
|
||||
if route.Method == method && matchRoute(route.Path, path) {
|
||||
mp.mu.RUnlock()
|
||||
|
||||
ctx, cancel := context.WithTimeout(m.ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := m.callHandleHTTP(ctx, mp, &pluginv1.HTTPRequest{
|
||||
Method: method,
|
||||
Path: path,
|
||||
Headers: headers,
|
||||
Body: body,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("plugin %s HandleHTTP failed: %w", name, err)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
}
|
||||
mp.mu.RUnlock()
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no plugin handles %s %s", method, path)
|
||||
}
|
||||
|
||||
// Status returns the status of a plugin
|
||||
func (mp *ManagedPlugin) Status() PluginStatus {
|
||||
mp.mu.RLock()
|
||||
defer mp.mu.RUnlock()
|
||||
return mp.status
|
||||
}
|
||||
|
||||
// Manifest returns the plugin's manifest
|
||||
func (mp *ManagedPlugin) Manifest() *pluginv1.PluginManifest {
|
||||
mp.mu.RLock()
|
||||
defer mp.mu.RUnlock()
|
||||
return mp.manifest
|
||||
}
|
||||
|
||||
// --- Internal methods ---
|
||||
|
||||
func (m *ExternalPluginManager) startManagedPlugin(mp *ManagedPlugin) error {
|
||||
args := strings.Fields(mp.config.Args)
|
||||
cmd := exec.Command(mp.config.Binary, args...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("failed to start binary %s: %w", mp.config.Binary, err)
|
||||
}
|
||||
|
||||
mp.process = cmd.Process
|
||||
|
||||
// Register with graceful manager for proper shutdown
|
||||
graceful.GetManager().RunAtShutdown(m.ctx, func() {
|
||||
if mp.process != nil {
|
||||
_ = mp.process.Signal(os.Interrupt)
|
||||
}
|
||||
})
|
||||
|
||||
// Wait a bit for the process to start
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *ExternalPluginManager) initializePlugin(mp *ManagedPlugin) error {
|
||||
req := &pluginv1.InitializeRequest{
|
||||
ServerVersion: "3.0.0",
|
||||
Config: map[string]string{},
|
||||
}
|
||||
|
||||
resp := &pluginv1.InitializeResponse{}
|
||||
if err := m.callRPC(mp, "initialize", req, resp); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !resp.Success {
|
||||
return fmt.Errorf("plugin initialization failed: %s", resp.Error)
|
||||
}
|
||||
|
||||
mp.mu.Lock()
|
||||
mp.manifest = resp.Manifest
|
||||
mp.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *ExternalPluginManager) shutdownPlugin(mp *ManagedPlugin) {
|
||||
req := &pluginv1.ShutdownRequest{Reason: "server shutdown"}
|
||||
resp := &pluginv1.ShutdownResponse{}
|
||||
if err := m.callRPC(mp, "shutdown", req, resp); err != nil {
|
||||
log.Warn("Plugin shutdown call failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *ExternalPluginManager) callOnEvent(ctx context.Context, mp *ManagedPlugin, event *pluginv1.PluginEvent) error {
|
||||
resp := &pluginv1.EventResponse{}
|
||||
if err := m.callRPCWithContext(ctx, mp, "on-event", event, resp); err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.Error != "" {
|
||||
return fmt.Errorf("plugin event error: %s", resp.Error)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *ExternalPluginManager) callHandleHTTP(ctx context.Context, mp *ManagedPlugin, req *pluginv1.HTTPRequest) (*pluginv1.HTTPResponse, error) {
|
||||
resp := &pluginv1.HTTPResponse{}
|
||||
if err := m.callRPCWithContext(ctx, mp, "handle-http", req, resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// callRPC makes a JSON-over-HTTP call to the plugin (simplified RPC)
|
||||
func (m *ExternalPluginManager) callRPC(mp *ManagedPlugin, method string, req, resp any) error {
|
||||
return m.callRPCWithContext(m.ctx, mp, method, req, resp)
|
||||
}
|
||||
|
||||
func (m *ExternalPluginManager) callRPCWithContext(ctx context.Context, mp *ManagedPlugin, method string, reqBody, respBody any) error {
|
||||
address := mp.config.Address
|
||||
if address == "" {
|
||||
return errors.New("plugin has no address configured")
|
||||
}
|
||||
|
||||
// Ensure address has scheme
|
||||
if !strings.HasPrefix(address, "http://") && !strings.HasPrefix(address, "https://") {
|
||||
address = "http://" + address
|
||||
}
|
||||
|
||||
url := address + "/plugin/v1/" + method
|
||||
|
||||
body, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
httpResp, err := mp.httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("RPC call to %s failed: %w", method, err)
|
||||
}
|
||||
defer httpResp.Body.Close()
|
||||
|
||||
respData, err := io.ReadAll(httpResp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
if httpResp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("RPC %s returned status %d: %s", method, httpResp.StatusCode, string(respData))
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(respData, respBody); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal response: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// matchRoute checks if a URL path matches a route pattern (simple prefix matching)
|
||||
func matchRoute(pattern, path string) bool {
|
||||
// Simple prefix match for now
|
||||
return strings.HasPrefix(path, pattern)
|
||||
}
|
||||
135
modules/plugins/health.go
Normal file
135
modules/plugins/health.go
Normal file
@@ -0,0 +1,135 @@
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"maps"
|
||||
"time"
|
||||
|
||||
"code.gitcaddy.com/server/v3/modules/graceful"
|
||||
"code.gitcaddy.com/server/v3/modules/log"
|
||||
pluginv1 "code.gitcaddy.com/server/v3/modules/plugins/pluginv1"
|
||||
)
|
||||
|
||||
const (
|
||||
maxConsecutiveFailures = 3
|
||||
)
|
||||
|
||||
// StartHealthMonitoring begins periodic health checks for all external plugins.
|
||||
// It runs as a background goroutine managed by the graceful manager.
|
||||
func (m *ExternalPluginManager) StartHealthMonitoring() {
|
||||
interval := m.config.HealthCheckInterval
|
||||
if interval <= 0 {
|
||||
interval = 30 * time.Second
|
||||
}
|
||||
|
||||
graceful.GetManager().RunWithShutdownContext(func(ctx context.Context) {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-m.ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
m.checkAllPlugins(ctx)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (m *ExternalPluginManager) checkAllPlugins(ctx context.Context) {
|
||||
m.mu.RLock()
|
||||
plugins := make(map[string]*ManagedPlugin, len(m.plugins))
|
||||
maps.Copy(plugins, m.plugins)
|
||||
m.mu.RUnlock()
|
||||
|
||||
for name, mp := range plugins {
|
||||
if err := m.checkPlugin(ctx, name, mp); err != nil {
|
||||
log.Warn("Health check failed for plugin %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *ExternalPluginManager) checkPlugin(ctx context.Context, name string, mp *ManagedPlugin) error {
|
||||
healthCtx, cancel := context.WithTimeout(ctx, mp.config.HealthTimeout)
|
||||
defer cancel()
|
||||
|
||||
resp := &pluginv1.HealthCheckResponse{}
|
||||
err := m.callRPCWithContext(healthCtx, mp, "health-check", &pluginv1.HealthCheckRequest{}, resp)
|
||||
|
||||
mp.mu.Lock()
|
||||
defer mp.mu.Unlock()
|
||||
|
||||
if err != nil {
|
||||
mp.failCount++
|
||||
if mp.failCount >= maxConsecutiveFailures {
|
||||
if mp.status != PluginStatusOffline {
|
||||
log.Error("Plugin %s is now offline after %d consecutive health check failures", name, mp.failCount)
|
||||
mp.status = PluginStatusOffline
|
||||
}
|
||||
|
||||
// Auto-restart managed plugins
|
||||
if mp.config.IsManaged() && mp.process != nil {
|
||||
log.Info("Attempting to restart managed plugin %s", name)
|
||||
go m.restartManagedPlugin(name, mp)
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Health check succeeded
|
||||
if mp.status != PluginStatusOnline {
|
||||
log.Info("Plugin %s is back online", name)
|
||||
}
|
||||
mp.failCount = 0
|
||||
mp.status = PluginStatusOnline
|
||||
mp.lastSeen = time.Now()
|
||||
|
||||
if !resp.Healthy {
|
||||
log.Warn("Plugin %s reports unhealthy: %s", name, resp.Status)
|
||||
mp.status = PluginStatusError
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *ExternalPluginManager) restartManagedPlugin(name string, mp *ManagedPlugin) {
|
||||
// Kill the old process first
|
||||
if mp.process != nil {
|
||||
_ = mp.process.Kill()
|
||||
mp.process = nil
|
||||
}
|
||||
|
||||
mp.mu.Lock()
|
||||
mp.status = PluginStatusStarting
|
||||
mp.mu.Unlock()
|
||||
|
||||
if err := m.startManagedPlugin(mp); err != nil {
|
||||
log.Error("Failed to restart managed plugin %s: %v", name, err)
|
||||
mp.mu.Lock()
|
||||
mp.status = PluginStatusError
|
||||
mp.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
if err := m.initializePlugin(mp); err != nil {
|
||||
log.Error("Failed to re-initialize managed plugin %s: %v", name, err)
|
||||
mp.mu.Lock()
|
||||
mp.status = PluginStatusError
|
||||
mp.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
mp.mu.Lock()
|
||||
mp.status = PluginStatusOnline
|
||||
mp.lastSeen = time.Now()
|
||||
mp.failCount = 0
|
||||
mp.mu.Unlock()
|
||||
|
||||
log.Info("Managed plugin %s restarted successfully", name)
|
||||
}
|
||||
97
modules/plugins/pluginv1/plugin.proto
Normal file
97
modules/plugins/pluginv1/plugin.proto
Normal file
@@ -0,0 +1,97 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package plugin.v1;
|
||||
|
||||
option go_package = "code.gitcaddy.com/server/v3/modules/plugins/pluginv1";
|
||||
|
||||
import "google/protobuf/struct.proto";
|
||||
import "google/protobuf/timestamp.proto";
|
||||
|
||||
// PluginService is the RPC interface that external plugins must implement.
|
||||
// The server calls these methods to manage the plugin's lifecycle and dispatch events.
|
||||
service PluginService {
|
||||
// Initialize is called when the server starts or the plugin is loaded
|
||||
rpc Initialize(InitializeRequest) returns (InitializeResponse);
|
||||
// Shutdown is called when the server is shutting down
|
||||
rpc Shutdown(ShutdownRequest) returns (ShutdownResponse);
|
||||
// HealthCheck checks if the plugin is healthy
|
||||
rpc HealthCheck(HealthCheckRequest) returns (HealthCheckResponse);
|
||||
// GetManifest returns the plugin's manifest describing its capabilities
|
||||
rpc GetManifest(GetManifestRequest) returns (PluginManifest);
|
||||
// OnEvent is called when an event the plugin is subscribed to occurs
|
||||
rpc OnEvent(PluginEvent) returns (EventResponse);
|
||||
// HandleHTTP proxies an HTTP request to the plugin
|
||||
rpc HandleHTTP(HTTPRequest) returns (HTTPResponse);
|
||||
}
|
||||
|
||||
message InitializeRequest {
|
||||
string server_version = 1;
|
||||
map<string, string> config = 2;
|
||||
}
|
||||
|
||||
message InitializeResponse {
|
||||
bool success = 1;
|
||||
string error = 2;
|
||||
PluginManifest manifest = 3;
|
||||
}
|
||||
|
||||
message ShutdownRequest {
|
||||
string reason = 1;
|
||||
}
|
||||
|
||||
message ShutdownResponse {
|
||||
bool success = 1;
|
||||
}
|
||||
|
||||
message HealthCheckRequest {}
|
||||
|
||||
message HealthCheckResponse {
|
||||
bool healthy = 1;
|
||||
string status = 2;
|
||||
map<string, string> details = 3;
|
||||
}
|
||||
|
||||
message GetManifestRequest {}
|
||||
|
||||
message PluginManifest {
|
||||
string name = 1;
|
||||
string version = 2;
|
||||
string description = 3;
|
||||
repeated string subscribed_events = 4;
|
||||
repeated PluginRoute routes = 5;
|
||||
repeated string required_permissions = 6;
|
||||
string license_tier = 7;
|
||||
}
|
||||
|
||||
message PluginRoute {
|
||||
string method = 1;
|
||||
string path = 2;
|
||||
string description = 3;
|
||||
}
|
||||
|
||||
message PluginEvent {
|
||||
string event_type = 1;
|
||||
google.protobuf.Struct payload = 2;
|
||||
google.protobuf.Timestamp timestamp = 3;
|
||||
int64 repo_id = 4;
|
||||
int64 org_id = 5;
|
||||
}
|
||||
|
||||
message EventResponse {
|
||||
bool handled = 1;
|
||||
string error = 2;
|
||||
}
|
||||
|
||||
message HTTPRequest {
|
||||
string method = 1;
|
||||
string path = 2;
|
||||
map<string, string> headers = 3;
|
||||
bytes body = 4;
|
||||
map<string, string> query_params = 5;
|
||||
}
|
||||
|
||||
message HTTPResponse {
|
||||
int32 status_code = 1;
|
||||
map<string, string> headers = 2;
|
||||
bytes body = 3;
|
||||
}
|
||||
81
modules/plugins/pluginv1/types.go
Normal file
81
modules/plugins/pluginv1/types.go
Normal file
@@ -0,0 +1,81 @@
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package pluginv1 defines the plugin service contract types.
|
||||
// These types mirror the plugin.proto definitions and will be replaced
|
||||
// by generated code when protoc-gen-go and protoc-gen-connect-go are run.
|
||||
package pluginv1
|
||||
|
||||
import "time"
|
||||
|
||||
type InitializeRequest struct {
|
||||
ServerVersion string `json:"server_version"`
|
||||
Config map[string]string `json:"config"`
|
||||
}
|
||||
|
||||
type InitializeResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error"`
|
||||
Manifest *PluginManifest `json:"manifest"`
|
||||
}
|
||||
|
||||
type ShutdownRequest struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type ShutdownResponse struct {
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
|
||||
type HealthCheckRequest struct{}
|
||||
|
||||
type HealthCheckResponse struct {
|
||||
Healthy bool `json:"healthy"`
|
||||
Status string `json:"status"`
|
||||
Details map[string]string `json:"details"`
|
||||
}
|
||||
|
||||
type GetManifestRequest struct{}
|
||||
|
||||
type PluginManifest struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Description string `json:"description"`
|
||||
SubscribedEvents []string `json:"subscribed_events"`
|
||||
Routes []PluginRoute `json:"routes"`
|
||||
RequiredPermissions []string `json:"required_permissions"`
|
||||
LicenseTier string `json:"license_tier"`
|
||||
}
|
||||
|
||||
type PluginRoute struct {
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type PluginEvent struct {
|
||||
EventType string `json:"event_type"`
|
||||
Payload map[string]any `json:"payload"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
RepoID int64 `json:"repo_id"`
|
||||
OrgID int64 `json:"org_id"`
|
||||
}
|
||||
|
||||
type EventResponse struct {
|
||||
Handled bool `json:"handled"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
type HTTPRequest struct {
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
Body []byte `json:"body"`
|
||||
QueryParams map[string]string `json:"query_params"`
|
||||
}
|
||||
|
||||
type HTTPResponse struct {
|
||||
StatusCode int `json:"status_code"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
Body []byte `json:"body"`
|
||||
}
|
||||
@@ -4619,6 +4619,16 @@
|
||||
"actions.runners.waiting_jobs": "Waiting Jobs",
|
||||
"actions.runners.back_to_runners": "Back to Runners",
|
||||
"actions.runners.no_waiting_jobs": "No jobs waiting for this label",
|
||||
"admin.ai": "AI Status",
|
||||
"admin.ai.title": "AI Service Status",
|
||||
"admin.ai.sidecar_status": "Sidecar Status",
|
||||
"admin.ai.config": "Configuration",
|
||||
"admin.ai.stats": "Statistics",
|
||||
"admin.ai.recent_operations": "Recent Operations",
|
||||
"admin.ai.total_operations": "Total Operations",
|
||||
"admin.ai.operations_24h": "Operations (24h)",
|
||||
"admin.ai.success_rate": "Success Rate",
|
||||
"admin.ai.tokens_used": "Tokens Used",
|
||||
"admin.ai_learning": "AI Learning",
|
||||
"admin.ai_learning.edit": "Edit Pattern",
|
||||
"admin.ai_learning.total_patterns": "Total Patterns",
|
||||
|
||||
@@ -72,7 +72,7 @@ func AIReviewPullRequest(ctx *context.APIContext) {
|
||||
return
|
||||
}
|
||||
|
||||
review, err := ai_service.ReviewPullRequest(ctx, pr)
|
||||
review, err := ai_service.ReviewPullRequest(ctx, pr, nil)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"error": err.Error(),
|
||||
@@ -141,7 +141,7 @@ func AITriageIssue(ctx *context.APIContext) {
|
||||
return
|
||||
}
|
||||
|
||||
triage, err := ai_service.TriageIssue(ctx, issue)
|
||||
triage, err := ai_service.TriageIssue(ctx, issue, nil)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"error": err.Error(),
|
||||
@@ -203,7 +203,7 @@ func AISuggestLabels(ctx *context.APIContext) {
|
||||
return
|
||||
}
|
||||
|
||||
suggestions, err := ai_service.SuggestLabels(ctx, issue)
|
||||
suggestions, err := ai_service.SuggestLabels(ctx, issue, nil)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"error": err.Error(),
|
||||
@@ -279,7 +279,7 @@ func AIExplainCode(ctx *context.APIContext) {
|
||||
return
|
||||
}
|
||||
|
||||
explanation, err := ai_service.ExplainCode(ctx, ctx.Repo.Repository, req.FilePath, req.Code, req.StartLine, req.EndLine, req.Question)
|
||||
explanation, err := ai_service.ExplainCode(ctx, ctx.Repo.Repository, req.FilePath, req.Code, req.StartLine, req.EndLine, req.Question, nil)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"error": err.Error(),
|
||||
@@ -355,7 +355,7 @@ func AIGenerateDocumentation(ctx *context.APIContext) {
|
||||
return
|
||||
}
|
||||
|
||||
docs, err := ai_service.GenerateDocumentation(ctx, ctx.Repo.Repository, req.FilePath, req.Code, req.DocType, req.Language, req.Style)
|
||||
docs, err := ai_service.GenerateDocumentation(ctx, ctx.Repo.Repository, req.FilePath, req.Code, req.DocType, req.Language, req.Style, nil)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"error": err.Error(),
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
issues_model "code.gitcaddy.com/server/v3/models/issues"
|
||||
repo_model "code.gitcaddy.com/server/v3/models/repo"
|
||||
"code.gitcaddy.com/server/v3/models/unit"
|
||||
ai_module "code.gitcaddy.com/server/v3/modules/ai"
|
||||
apierrors "code.gitcaddy.com/server/v3/modules/errors"
|
||||
"code.gitcaddy.com/server/v3/modules/setting"
|
||||
api "code.gitcaddy.com/server/v3/modules/structs"
|
||||
@@ -35,6 +36,31 @@ func getRepoAIConfig(ctx *context.APIContext) *repo_model.AIConfig {
|
||||
return aiUnit.AIConfig()
|
||||
}
|
||||
|
||||
// resolveProviderConfig builds a ProviderConfig from the repo/org/system cascade.
|
||||
func resolveProviderConfig(ctx *context.APIContext) *ai_module.ProviderConfig {
|
||||
var orgID int64
|
||||
if ctx.Repo.Repository.Owner.IsOrganization() {
|
||||
orgID = ctx.Repo.Repository.OwnerID
|
||||
}
|
||||
|
||||
var repoProvider, repoModel string
|
||||
if aiUnit, err := ctx.Repo.Repository.GetUnit(ctx, unit.TypeAI); err == nil {
|
||||
cfg := aiUnit.AIConfig()
|
||||
repoProvider = cfg.PreferredProvider
|
||||
repoModel = cfg.PreferredModel
|
||||
}
|
||||
|
||||
provider := ai_model.ResolveProvider(ctx, orgID, repoProvider)
|
||||
model := ai_model.ResolveModel(ctx, orgID, repoModel)
|
||||
apiKey := ai_model.ResolveAPIKey(ctx, orgID, provider)
|
||||
|
||||
return &ai_module.ProviderConfig{
|
||||
Provider: provider,
|
||||
Model: model,
|
||||
APIKey: apiKey,
|
||||
}
|
||||
}
|
||||
|
||||
func toAIOperationV2(op *ai_model.OperationLog) *api.AIOperationV2 {
|
||||
return &api.AIOperationV2{
|
||||
ID: op.ID,
|
||||
@@ -385,7 +411,9 @@ func TriggerAIExplain(ctx *context.APIContext) {
|
||||
|
||||
form := web.GetForm(ctx).(*api.AIExplainRequest)
|
||||
|
||||
resp, err := ai.ExplainCode(ctx, ctx.Repo.Repository, form.FilePath, "", form.StartLine, form.EndLine, form.Question)
|
||||
providerCfg := resolveProviderConfig(ctx)
|
||||
|
||||
resp, err := ai.ExplainCode(ctx, ctx.Repo.Repository, form.FilePath, "", form.StartLine, form.EndLine, form.Question, providerCfg)
|
||||
if err != nil {
|
||||
ctx.APIErrorWithCode(apierrors.AIServiceError, map[string]any{
|
||||
"detail": err.Error(),
|
||||
|
||||
@@ -161,11 +161,21 @@ func InitWebInstalled(ctx context.Context) {
|
||||
log.Fatal("Plugin migrations failed: %v", err)
|
||||
}
|
||||
|
||||
// Initialize all plugins
|
||||
// Initialize all compiled plugins
|
||||
if err := plugins.InitAll(ctx); err != nil {
|
||||
log.Fatal("Plugin initialization failed: %v", err)
|
||||
}
|
||||
|
||||
// Initialize external plugin manager (Phase 5)
|
||||
pluginCfg := plugins.LoadConfig()
|
||||
if pluginCfg.Enabled && len(pluginCfg.ExternalPlugins) > 0 {
|
||||
extManager := plugins.NewExternalPluginManager(pluginCfg)
|
||||
if err := extManager.StartAll(); err != nil {
|
||||
log.Error("External plugin startup had errors: %v", err)
|
||||
}
|
||||
extManager.StartHealthMonitoring()
|
||||
}
|
||||
|
||||
mustInit(system.Init)
|
||||
mustInitCtx(ctx, oauth2.Init)
|
||||
mustInitCtx(ctx, oauth2_provider.Init)
|
||||
|
||||
77
routers/web/admin/ai.go
Normal file
77
routers/web/admin/ai.go
Normal file
@@ -0,0 +1,77 @@
|
||||
// Copyright 2026 MarketAlly. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
ai_model "code.gitcaddy.com/server/v3/models/ai"
|
||||
"code.gitcaddy.com/server/v3/models/db"
|
||||
ai_module "code.gitcaddy.com/server/v3/modules/ai"
|
||||
"code.gitcaddy.com/server/v3/modules/setting"
|
||||
"code.gitcaddy.com/server/v3/modules/templates"
|
||||
"code.gitcaddy.com/server/v3/services/context"
|
||||
)
|
||||
|
||||
const (
|
||||
tplAI templates.TplName = "admin/ai"
|
||||
)
|
||||
|
||||
// AIStatus shows the AI service status admin dashboard
|
||||
func AIStatus(ctx *context.Context) {
|
||||
ctx.Data["Title"] = ctx.Tr("admin.ai.title")
|
||||
ctx.Data["PageIsAdminAI"] = true
|
||||
|
||||
// Check sidecar health
|
||||
var sidecarHealthy bool
|
||||
var sidecarVersion string
|
||||
var providerStatus map[string]string
|
||||
|
||||
if setting.AI.Enabled {
|
||||
health, err := ai_module.GetClient().CheckHealth(ctx)
|
||||
if err == nil && health != nil {
|
||||
sidecarHealthy = health.Healthy
|
||||
sidecarVersion = health.Version
|
||||
providerStatus = health.ProviderStatus
|
||||
}
|
||||
}
|
||||
|
||||
ctx.Data["SidecarHealthy"] = sidecarHealthy
|
||||
ctx.Data["SidecarVersion"] = sidecarVersion
|
||||
ctx.Data["ProviderStatus"] = providerStatus
|
||||
|
||||
// Load global operation stats
|
||||
stats, err := ai_model.GetGlobalOperationStats(ctx)
|
||||
if err != nil {
|
||||
ctx.ServerError("GetGlobalOperationStats", err)
|
||||
return
|
||||
}
|
||||
ctx.Data["Stats"] = stats
|
||||
|
||||
// Calculate success rate
|
||||
var successRate float64
|
||||
if stats.TotalOperations > 0 {
|
||||
successRate = float64(stats.SuccessCount) / float64(stats.TotalOperations) * 100
|
||||
}
|
||||
ctx.Data["SuccessRate"] = successRate
|
||||
ctx.Data["TotalTokens"] = stats.TotalInputTokens + stats.TotalOutputTokens
|
||||
|
||||
// Load recent operations (last 20)
|
||||
recentOps, err := db.Find[ai_model.OperationLog](ctx, ai_model.FindOperationLogsOptions{
|
||||
ListOptions: db.ListOptions{
|
||||
Page: 1,
|
||||
PageSize: 20,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
ctx.ServerError("FindOperationLogs", err)
|
||||
return
|
||||
}
|
||||
ctx.Data["RecentOps"] = recentOps
|
||||
|
||||
// Pass AI config for display
|
||||
ctx.Data["AIConfig"] = setting.AI
|
||||
|
||||
ctx.HTML(http.StatusOK, tplAI)
|
||||
}
|
||||
@@ -43,7 +43,7 @@ func AIReviewPullRequest(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
review, err := ai_service.ReviewPullRequest(ctx, pr)
|
||||
review, err := ai_service.ReviewPullRequest(ctx, pr, nil)
|
||||
if err != nil {
|
||||
ctx.Flash.Error(ctx.Tr("repo.ai.review_failed", err.Error()))
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/pulls/" + ctx.PathParam("index"))
|
||||
@@ -77,7 +77,7 @@ func AITriageIssue(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
triage, err := ai_service.TriageIssue(ctx, issue)
|
||||
triage, err := ai_service.TriageIssue(ctx, issue, nil)
|
||||
if err != nil {
|
||||
ctx.Flash.Error(ctx.Tr("repo.ai.triage_failed", err.Error()))
|
||||
ctx.Redirect(ctx.Repo.RepoLink + "/issues/" + ctx.PathParam("index"))
|
||||
@@ -108,7 +108,7 @@ func AISuggestLabels(ctx *context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
suggestions, err := ai_service.SuggestLabels(ctx, issue)
|
||||
suggestions, err := ai_service.SuggestLabels(ctx, issue, nil)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, map[string]string{
|
||||
"error": err.Error(),
|
||||
|
||||
@@ -888,6 +888,8 @@ func registerWebRoutes(m *web.Router) {
|
||||
m.Post("/empty", admin.EmptyNotices)
|
||||
})
|
||||
|
||||
m.Get("/ai", admin.AIStatus)
|
||||
|
||||
m.Group("/ai-learning", func() {
|
||||
m.Get("", admin.AILearning)
|
||||
m.Get("/{id}", admin.AILearningEdit)
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"code.gitcaddy.com/server/v3/models/db"
|
||||
@@ -25,8 +26,9 @@ func IsEnabled() bool {
|
||||
return ai.IsEnabled()
|
||||
}
|
||||
|
||||
// ReviewPullRequest performs an AI review of a pull request
|
||||
func ReviewPullRequest(ctx context.Context, pr *issues_model.PullRequest) (*ai.ReviewPullRequestResponse, error) {
|
||||
// ReviewPullRequest performs an AI review of a pull request.
|
||||
// providerCfg may be nil, in which case the sidecar uses its defaults.
|
||||
func ReviewPullRequest(ctx context.Context, pr *issues_model.PullRequest, providerCfg *ai.ProviderConfig) (*ai.ReviewPullRequestResponse, error) {
|
||||
if !IsEnabled() || !setting.AI.EnableCodeReview {
|
||||
return nil, errors.New("AI code review is not enabled")
|
||||
}
|
||||
@@ -88,13 +90,14 @@ func ReviewPullRequest(ctx context.Context, pr *issues_model.PullRequest) (*ai.R
|
||||
}
|
||||
|
||||
req := &ai.ReviewPullRequestRequest{
|
||||
RepoID: pr.BaseRepoID,
|
||||
PullRequestID: pr.ID,
|
||||
BaseBranch: pr.BaseBranch,
|
||||
HeadBranch: pr.HeadBranch,
|
||||
Files: files,
|
||||
PRTitle: pr.Issue.Title,
|
||||
PRDescription: pr.Issue.Content,
|
||||
ProviderConfig: providerCfg,
|
||||
RepoID: pr.BaseRepoID,
|
||||
PullRequestID: pr.ID,
|
||||
BaseBranch: pr.BaseBranch,
|
||||
HeadBranch: pr.HeadBranch,
|
||||
Files: files,
|
||||
PRTitle: pr.Issue.Title,
|
||||
PRDescription: pr.Issue.Content,
|
||||
Options: ai.ReviewOptions{
|
||||
CheckSecurity: true,
|
||||
CheckPerformance: true,
|
||||
@@ -114,8 +117,9 @@ func ReviewPullRequest(ctx context.Context, pr *issues_model.PullRequest) (*ai.R
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// TriageIssue performs AI triage on an issue
|
||||
func TriageIssue(ctx context.Context, issue *issues_model.Issue) (*ai.TriageIssueResponse, error) {
|
||||
// TriageIssue performs AI triage on an issue.
|
||||
// providerCfg may be nil, in which case the sidecar uses its defaults.
|
||||
func TriageIssue(ctx context.Context, issue *issues_model.Issue, providerCfg *ai.ProviderConfig) (*ai.TriageIssueResponse, error) {
|
||||
if !IsEnabled() || !setting.AI.EnableIssueTriage {
|
||||
return nil, errors.New("AI issue triage is not enabled")
|
||||
}
|
||||
@@ -145,6 +149,7 @@ func TriageIssue(ctx context.Context, issue *issues_model.Issue) (*ai.TriageIssu
|
||||
}
|
||||
|
||||
req := &ai.TriageIssueRequest{
|
||||
ProviderConfig: providerCfg,
|
||||
RepoID: issue.RepoID,
|
||||
IssueID: issue.ID,
|
||||
Title: issue.Title,
|
||||
@@ -163,8 +168,9 @@ func TriageIssue(ctx context.Context, issue *issues_model.Issue) (*ai.TriageIssu
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// SuggestLabels suggests labels for an issue
|
||||
func SuggestLabels(ctx context.Context, issue *issues_model.Issue) (*ai.SuggestLabelsResponse, error) {
|
||||
// SuggestLabels suggests labels for an issue.
|
||||
// providerCfg may be nil, in which case the sidecar uses its defaults.
|
||||
func SuggestLabels(ctx context.Context, issue *issues_model.Issue, providerCfg *ai.ProviderConfig) (*ai.SuggestLabelsResponse, error) {
|
||||
if !IsEnabled() || !setting.AI.EnableIssueTriage {
|
||||
return nil, errors.New("AI issue triage is not enabled")
|
||||
}
|
||||
@@ -185,6 +191,7 @@ func SuggestLabels(ctx context.Context, issue *issues_model.Issue) (*ai.SuggestL
|
||||
}
|
||||
|
||||
req := &ai.SuggestLabelsRequest{
|
||||
ProviderConfig: providerCfg,
|
||||
RepoID: issue.RepoID,
|
||||
Title: issue.Title,
|
||||
Body: issue.Content,
|
||||
@@ -201,19 +208,21 @@ func SuggestLabels(ctx context.Context, issue *issues_model.Issue) (*ai.SuggestL
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// ExplainCode provides an AI explanation of code
|
||||
func ExplainCode(ctx context.Context, repo *repo_model.Repository, filePath, code string, startLine, endLine int, question string) (*ai.ExplainCodeResponse, error) {
|
||||
// ExplainCode provides an AI explanation of code.
|
||||
// providerCfg may be nil, in which case the sidecar uses its defaults.
|
||||
func ExplainCode(ctx context.Context, repo *repo_model.Repository, filePath, code string, startLine, endLine int, question string, providerCfg *ai.ProviderConfig) (*ai.ExplainCodeResponse, error) {
|
||||
if !IsEnabled() || !setting.AI.EnableExplainCode {
|
||||
return nil, errors.New("AI code explanation is not enabled")
|
||||
}
|
||||
|
||||
req := &ai.ExplainCodeRequest{
|
||||
RepoID: repo.ID,
|
||||
FilePath: filePath,
|
||||
Code: code,
|
||||
StartLine: startLine,
|
||||
EndLine: endLine,
|
||||
Question: question,
|
||||
ProviderConfig: providerCfg,
|
||||
RepoID: repo.ID,
|
||||
FilePath: filePath,
|
||||
Code: code,
|
||||
StartLine: startLine,
|
||||
EndLine: endLine,
|
||||
Question: question,
|
||||
}
|
||||
|
||||
client := ai.GetClient()
|
||||
@@ -226,19 +235,21 @@ func ExplainCode(ctx context.Context, repo *repo_model.Repository, filePath, cod
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// GenerateDocumentation generates documentation for code
|
||||
func GenerateDocumentation(ctx context.Context, repo *repo_model.Repository, filePath, code, docType, language, style string) (*ai.GenerateDocumentationResponse, error) {
|
||||
// GenerateDocumentation generates documentation for code.
|
||||
// providerCfg may be nil, in which case the sidecar uses its defaults.
|
||||
func GenerateDocumentation(ctx context.Context, repo *repo_model.Repository, filePath, code, docType, language, style string, providerCfg *ai.ProviderConfig) (*ai.GenerateDocumentationResponse, error) {
|
||||
if !IsEnabled() || !setting.AI.EnableDocGen {
|
||||
return nil, errors.New("AI documentation generation is not enabled")
|
||||
}
|
||||
|
||||
req := &ai.GenerateDocumentationRequest{
|
||||
RepoID: repo.ID,
|
||||
FilePath: filePath,
|
||||
Code: code,
|
||||
DocType: docType,
|
||||
Language: language,
|
||||
Style: style,
|
||||
ProviderConfig: providerCfg,
|
||||
RepoID: repo.ID,
|
||||
FilePath: filePath,
|
||||
Code: code,
|
||||
DocType: docType,
|
||||
Language: language,
|
||||
Style: style,
|
||||
}
|
||||
|
||||
client := ai.GetClient()
|
||||
@@ -251,8 +262,9 @@ func GenerateDocumentation(ctx context.Context, repo *repo_model.Repository, fil
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// GenerateCommitMessage generates a commit message for staged changes
|
||||
func GenerateCommitMessage(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, style string) (*ai.GenerateCommitMessageResponse, error) {
|
||||
// GenerateCommitMessage generates a commit message for staged changes.
|
||||
// providerCfg may be nil, in which case the sidecar uses its defaults.
|
||||
func GenerateCommitMessage(ctx context.Context, repo *repo_model.Repository, gitRepo *git.Repository, style string, providerCfg *ai.ProviderConfig) (*ai.GenerateCommitMessageResponse, error) {
|
||||
if !IsEnabled() || !setting.AI.EnableDocGen {
|
||||
return nil, errors.New("AI documentation generation is not enabled")
|
||||
}
|
||||
@@ -260,9 +272,10 @@ func GenerateCommitMessage(ctx context.Context, repo *repo_model.Repository, git
|
||||
// This would be called from the web editor
|
||||
// For now, return a placeholder
|
||||
req := &ai.GenerateCommitMessageRequest{
|
||||
RepoID: repo.ID,
|
||||
Files: []ai.FileDiff{},
|
||||
Style: style,
|
||||
ProviderConfig: providerCfg,
|
||||
RepoID: repo.ID,
|
||||
Files: []ai.FileDiff{},
|
||||
Style: style,
|
||||
}
|
||||
|
||||
client := ai.GetClient()
|
||||
@@ -275,6 +288,61 @@ func GenerateCommitMessage(ctx context.Context, repo *repo_model.Repository, git
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// InspectWorkflow inspects a workflow YAML file using AI.
|
||||
// providerCfg may be nil, in which case the sidecar uses its defaults.
|
||||
func InspectWorkflow(ctx context.Context, repo *repo_model.Repository, filePath, content string, providerCfg *ai.ProviderConfig) (*ai.InspectWorkflowResponse, error) {
|
||||
if !IsEnabled() {
|
||||
return nil, errors.New("AI is not enabled")
|
||||
}
|
||||
|
||||
// If content is empty, try to read from the repo
|
||||
if content == "" && filePath != "" {
|
||||
gitRepo, err := gitrepo.OpenRepository(ctx, repo)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open git repo: %w", err)
|
||||
}
|
||||
defer gitRepo.Close()
|
||||
|
||||
commit, err := gitRepo.GetBranchCommit(repo.DefaultBranch)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get default branch commit: %w", err)
|
||||
}
|
||||
|
||||
blob, err := commit.GetBlobByPath(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read workflow file %s: %w", filePath, err)
|
||||
}
|
||||
|
||||
reader, err := blob.DataAsync()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read blob data: %w", err)
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read workflow content: %w", err)
|
||||
}
|
||||
content = string(data)
|
||||
}
|
||||
|
||||
req := &ai.InspectWorkflowRequest{
|
||||
ProviderConfig: providerCfg,
|
||||
RepoID: repo.ID,
|
||||
FilePath: filePath,
|
||||
Content: content,
|
||||
}
|
||||
|
||||
client := ai.GetClient()
|
||||
resp, err := client.InspectWorkflow(ctx, req)
|
||||
if err != nil {
|
||||
log.Error("AI InspectWorkflow failed: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// getFileStatus returns the status string for a file diff
|
||||
func getFileStatus(file *gitdiff.DiffFile) string {
|
||||
if file.IsDeleted {
|
||||
|
||||
@@ -6,12 +6,15 @@ package ai
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
issues_model "code.gitcaddy.com/server/v3/models/issues"
|
||||
repo_model "code.gitcaddy.com/server/v3/models/repo"
|
||||
"code.gitcaddy.com/server/v3/models/unit"
|
||||
user_model "code.gitcaddy.com/server/v3/models/user"
|
||||
"code.gitcaddy.com/server/v3/modules/gitrepo"
|
||||
"code.gitcaddy.com/server/v3/modules/log"
|
||||
"code.gitcaddy.com/server/v3/modules/repository"
|
||||
"code.gitcaddy.com/server/v3/modules/setting"
|
||||
notify_service "code.gitcaddy.com/server/v3/services/notify"
|
||||
)
|
||||
@@ -245,6 +248,79 @@ func (n *aiNotifier) IssueChangeLabels(ctx context.Context, doer *user_model.Use
|
||||
}
|
||||
}
|
||||
|
||||
// PushCommits handles push events — triggers workflow-inspect if workflow files changed
|
||||
func (n *aiNotifier) PushCommits(ctx context.Context, pusher *user_model.User, repo *repo_model.Repository,
|
||||
opts *repository.PushUpdateOptions, commits *repository.PushCommits,
|
||||
) {
|
||||
if isAIUser(pusher) {
|
||||
return
|
||||
}
|
||||
|
||||
// Only inspect on pushes to the default branch
|
||||
if opts.RefFullName.BranchName() != repo.DefaultBranch {
|
||||
return
|
||||
}
|
||||
|
||||
aiCfg := getAIConfig(ctx, repo)
|
||||
if aiCfg == nil || !aiCfg.AutoInspectWorkflows {
|
||||
return
|
||||
}
|
||||
|
||||
if !setting.AI.Enabled {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if any pushed commit touched workflow files
|
||||
workflowFiles := findChangedWorkflowFiles(ctx, repo, opts)
|
||||
for _, filePath := range workflowFiles {
|
||||
if err := EnqueueOperation(&OperationRequest{
|
||||
RepoID: repo.ID,
|
||||
Operation: "workflow-inspect",
|
||||
Tier: 1,
|
||||
TriggerEvent: "push.workflow_changed",
|
||||
TriggerUserID: pusher.ID,
|
||||
TargetID: 0,
|
||||
TargetType: "workflow",
|
||||
}); err != nil {
|
||||
log.Error("AI notifier: failed to enqueue workflow-inspect for %s in repo %d: %v", filePath, repo.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// findChangedWorkflowFiles returns workflow file paths that changed between old and new commits
|
||||
func findChangedWorkflowFiles(ctx context.Context, repo *repo_model.Repository, opts *repository.PushUpdateOptions) []string {
|
||||
if opts.OldCommitID == "" || opts.NewCommitID == "" || opts.IsNewRef() {
|
||||
return nil
|
||||
}
|
||||
|
||||
gitRepo, err := gitrepo.OpenRepository(ctx, repo)
|
||||
if err != nil {
|
||||
log.Error("AI notifier: failed to open git repo %d: %v", repo.ID, err)
|
||||
return nil
|
||||
}
|
||||
defer gitRepo.Close()
|
||||
|
||||
files, err := gitRepo.GetFilesChangedBetween(opts.OldCommitID, opts.NewCommitID)
|
||||
if err != nil {
|
||||
log.Error("AI notifier: failed to get changed files: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
var workflowFiles []string
|
||||
for _, f := range files {
|
||||
if isWorkflowFile(f) {
|
||||
workflowFiles = append(workflowFiles, f)
|
||||
}
|
||||
}
|
||||
return workflowFiles
|
||||
}
|
||||
|
||||
// isWorkflowFile returns true if the path is a workflow file
|
||||
func isWorkflowFile(path string) bool {
|
||||
return (strings.HasPrefix(path, ".gitea/workflows/") || strings.HasPrefix(path, ".github/workflows/")) &&
|
||||
(strings.HasSuffix(path, ".yml") || strings.HasSuffix(path, ".yaml"))
|
||||
}
|
||||
|
||||
// isBotMentioned checks if the AI bot user is mentioned in text
|
||||
func isBotMentioned(content string) bool {
|
||||
botName := setting.AI.BotUserName
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
ai_model "code.gitcaddy.com/server/v3/models/ai"
|
||||
@@ -105,6 +107,13 @@ func processOperation(ctx context.Context, req *OperationRequest) error {
|
||||
opLog.Provider = ai_model.ResolveProvider(ctx, orgID, aiCfg.PreferredProvider)
|
||||
opLog.Model = ai_model.ResolveModel(ctx, orgID, aiCfg.PreferredModel)
|
||||
|
||||
// Build per-request provider config from the cascade
|
||||
providerCfg := &ai.ProviderConfig{
|
||||
Provider: opLog.Provider,
|
||||
Model: opLog.Model,
|
||||
APIKey: ai_model.ResolveAPIKey(ctx, orgID, opLog.Provider),
|
||||
}
|
||||
|
||||
if err := ai_model.InsertOperationLog(ctx, opLog); err != nil {
|
||||
return fmt.Errorf("failed to insert operation log: %w", err)
|
||||
}
|
||||
@@ -115,11 +124,13 @@ func processOperation(ctx context.Context, req *OperationRequest) error {
|
||||
var opErr error
|
||||
switch req.Operation {
|
||||
case "issue-response":
|
||||
opErr = handleIssueResponse(ctx, repo, aiCfg, opLog)
|
||||
opErr = handleIssueResponse(ctx, repo, aiCfg, opLog, providerCfg)
|
||||
case "issue-triage":
|
||||
opErr = handleIssueTriage(ctx, repo, aiCfg, opLog)
|
||||
opErr = handleIssueTriage(ctx, repo, opLog, providerCfg)
|
||||
case "code-review":
|
||||
opErr = handleCodeReview(ctx, repo, aiCfg, opLog)
|
||||
opErr = handleCodeReview(ctx, repo, opLog, providerCfg)
|
||||
case "workflow-inspect":
|
||||
opErr = handleWorkflowInspect(ctx, repo, opLog, providerCfg)
|
||||
case "agent-fix":
|
||||
opErr = handleAgentFix(ctx, repo, aiCfg, opLog)
|
||||
default:
|
||||
@@ -148,7 +159,7 @@ func processOperation(ctx context.Context, req *OperationRequest) error {
|
||||
return opErr
|
||||
}
|
||||
|
||||
func handleIssueResponse(ctx context.Context, repo *repo_model.Repository, aiCfg *repo_model.AIConfig, opLog *ai_model.OperationLog) error {
|
||||
func handleIssueResponse(ctx context.Context, repo *repo_model.Repository, aiCfg *repo_model.AIConfig, opLog *ai_model.OperationLog, providerCfg *ai.ProviderConfig) error {
|
||||
issue, err := issues_model.GetIssueByID(ctx, opLog.TargetID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load issue %d: %w", opLog.TargetID, err)
|
||||
@@ -157,6 +168,7 @@ func handleIssueResponse(ctx context.Context, repo *repo_model.Repository, aiCfg
|
||||
|
||||
client := ai.GetClient()
|
||||
resp, err := client.GenerateIssueResponse(ctx, &ai.GenerateIssueResponseRequest{
|
||||
ProviderConfig: providerCfg,
|
||||
RepoID: repo.ID,
|
||||
IssueID: issue.ID,
|
||||
Title: issue.Title,
|
||||
@@ -181,14 +193,14 @@ func handleIssueResponse(ctx context.Context, repo *repo_model.Repository, aiCfg
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleIssueTriage(ctx context.Context, repo *repo_model.Repository, _ *repo_model.AIConfig, opLog *ai_model.OperationLog) error {
|
||||
func handleIssueTriage(ctx context.Context, repo *repo_model.Repository, opLog *ai_model.OperationLog, providerCfg *ai.ProviderConfig) error {
|
||||
issue, err := issues_model.GetIssueByID(ctx, opLog.TargetID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load issue %d: %w", opLog.TargetID, err)
|
||||
}
|
||||
issue.Repo = repo
|
||||
|
||||
triageResp, err := TriageIssue(ctx, issue)
|
||||
triageResp, err := TriageIssue(ctx, issue, providerCfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("AI TriageIssue failed: %w", err)
|
||||
}
|
||||
@@ -209,7 +221,7 @@ func handleIssueTriage(ctx context.Context, repo *repo_model.Repository, _ *repo
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleCodeReview(ctx context.Context, repo *repo_model.Repository, _ *repo_model.AIConfig, opLog *ai_model.OperationLog) error {
|
||||
func handleCodeReview(ctx context.Context, repo *repo_model.Repository, opLog *ai_model.OperationLog, providerCfg *ai.ProviderConfig) error {
|
||||
issue, err := issues_model.GetIssueByID(ctx, opLog.TargetID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load issue %d: %w", opLog.TargetID, err)
|
||||
@@ -219,7 +231,7 @@ func handleCodeReview(ctx context.Context, repo *repo_model.Repository, _ *repo_
|
||||
}
|
||||
issue.Repo = repo
|
||||
|
||||
reviewResp, err := ReviewPullRequest(ctx, issue.PullRequest)
|
||||
reviewResp, err := ReviewPullRequest(ctx, issue.PullRequest, providerCfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("AI ReviewPullRequest failed: %w", err)
|
||||
}
|
||||
@@ -245,3 +257,62 @@ func handleAgentFix(ctx context.Context, repo *repo_model.Repository, aiCfg *rep
|
||||
opLog.ActionRunID = runID
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleWorkflowInspect(ctx context.Context, repo *repo_model.Repository, opLog *ai_model.OperationLog, providerCfg *ai.ProviderConfig) error {
|
||||
// TargetID is used to store context; for workflow inspect the file path is stored in ErrorMessage temporarily
|
||||
// We use the operation's ErrorMessage field pre-populated with the file path before dispatch
|
||||
filePath := opLog.ErrorMessage
|
||||
opLog.ErrorMessage = "" // Clear it before actual use
|
||||
|
||||
resp, err := InspectWorkflow(ctx, repo, filePath, "", providerCfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("AI InspectWorkflow failed: %w", err)
|
||||
}
|
||||
|
||||
opLog.InputTokens = resp.InputTokens
|
||||
opLog.OutputTokens = resp.OutputTokens
|
||||
|
||||
// If there are issues, post a summary comment (for push-triggered inspections)
|
||||
if len(resp.Issues) > 0 || len(resp.Suggestions) > 0 {
|
||||
var body strings.Builder
|
||||
body.WriteString("## Workflow Inspection Results\n\n")
|
||||
body.WriteString("**File:** `" + filePath + "`\n\n")
|
||||
|
||||
if !resp.Valid {
|
||||
body.WriteString("**Status:** Issues found\n\n")
|
||||
}
|
||||
|
||||
for _, issue := range resp.Issues {
|
||||
icon := "ℹ️"
|
||||
switch issue.Severity {
|
||||
case "error":
|
||||
icon = "❌"
|
||||
case "warning":
|
||||
icon = "⚠️"
|
||||
}
|
||||
body.WriteString(icon + " ")
|
||||
if issue.Line > 0 {
|
||||
body.WriteString("**Line " + strconv.Itoa(issue.Line) + ":** ")
|
||||
}
|
||||
body.WriteString(issue.Message + "\n")
|
||||
if issue.Fix != "" {
|
||||
body.WriteString(" - **Fix:** " + issue.Fix + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
if len(resp.Suggestions) > 0 {
|
||||
body.WriteString("\n### Suggestions\n")
|
||||
for _, s := range resp.Suggestions {
|
||||
body.WriteString("- " + s + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
log.Info("Workflow inspection for %s in repo %d: %d issues, %d suggestions",
|
||||
filePath, repo.ID, len(resp.Issues), len(resp.Suggestions))
|
||||
// Note: for push-triggered inspections, the comment would be posted as a repo event
|
||||
// or as part of the commit status. The body is logged for now.
|
||||
_ = body.String()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
224
templates/admin/ai.tmpl
Normal file
224
templates/admin/ai.tmpl
Normal file
@@ -0,0 +1,224 @@
|
||||
{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin ai")}}
|
||||
<div class="admin ai">
|
||||
<h4 class="ui top attached header">
|
||||
{{ctx.Locale.Tr "admin.ai.title"}}
|
||||
</h4>
|
||||
|
||||
<!-- Stat Tiles -->
|
||||
<div class="ui attached segment">
|
||||
<div style="display: flex; justify-content: space-around; text-align: center; flex-wrap: wrap; gap: 10px;">
|
||||
<!-- Sidecar Status -->
|
||||
<div style="padding: 15px 25px; background: var(--color-box-body); border: 1px solid var(--color-secondary); border-radius: 8px; min-width: 150px;">
|
||||
{{if .SidecarHealthy}}
|
||||
<div style="font-size: 2em; font-weight: bold; color: #21ba45;">{{svg "octicon-check-circle-fill" 32}}</div>
|
||||
<div style="color: #21ba45; font-weight: bold; margin-top: 5px;">Online</div>
|
||||
{{else}}
|
||||
<div style="font-size: 2em; font-weight: bold; color: #db2828;">{{svg "octicon-x-circle-fill" 32}}</div>
|
||||
<div style="color: #db2828; font-weight: bold; margin-top: 5px;">Offline</div>
|
||||
{{end}}
|
||||
<div style="color: var(--color-text-light); margin-top: 5px;">{{ctx.Locale.Tr "admin.ai.sidecar_status"}}</div>
|
||||
{{if .SidecarVersion}}<div style="color: var(--color-text-light); font-size: 0.85em;">v{{.SidecarVersion}}</div>{{end}}
|
||||
</div>
|
||||
<!-- Total Ops -->
|
||||
<div style="padding: 15px 25px; background: var(--color-box-body); border: 1px solid var(--color-secondary); border-radius: 8px; min-width: 150px;">
|
||||
<div style="font-size: 2em; font-weight: bold; color: #2185d0;">{{.Stats.TotalOperations}}</div>
|
||||
<div style="color: var(--color-text-light); margin-top: 5px;">{{ctx.Locale.Tr "admin.ai.total_operations"}}</div>
|
||||
</div>
|
||||
<!-- 24h Ops -->
|
||||
<div style="padding: 15px 25px; background: var(--color-box-body); border: 1px solid var(--color-secondary); border-radius: 8px; min-width: 150px;">
|
||||
<div style="font-size: 2em; font-weight: bold; color: #6435c9;">{{.Stats.Operations24h}}</div>
|
||||
<div style="color: var(--color-text-light); margin-top: 5px;">{{ctx.Locale.Tr "admin.ai.operations_24h"}}</div>
|
||||
</div>
|
||||
<!-- Success Rate -->
|
||||
<div style="padding: 15px 25px; background: var(--color-box-body); border: 1px solid var(--color-secondary); border-radius: 8px; min-width: 150px;">
|
||||
<div style="font-size: 2em; font-weight: bold; color: #21ba45;">{{printf "%.1f" .SuccessRate}}%</div>
|
||||
<div style="color: var(--color-text-light); margin-top: 5px;">{{ctx.Locale.Tr "admin.ai.success_rate"}}</div>
|
||||
</div>
|
||||
<!-- Tokens Used -->
|
||||
<div style="padding: 15px 25px; background: var(--color-box-body); border: 1px solid var(--color-secondary); border-radius: 8px; min-width: 150px;">
|
||||
<div style="font-size: 2em; font-weight: bold; color: #f2711c;">{{.TotalTokens}}</div>
|
||||
<div style="color: var(--color-text-light); margin-top: 5px;">{{ctx.Locale.Tr "admin.ai.tokens_used"}}</div>
|
||||
<div style="color: var(--color-text-light); font-size: 0.85em;">In: {{.Stats.TotalInputTokens}} / Out: {{.Stats.TotalOutputTokens}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Configuration Summary -->
|
||||
<h4 class="ui attached header">
|
||||
{{ctx.Locale.Tr "admin.ai.config"}}
|
||||
</h4>
|
||||
<div class="ui attached segment">
|
||||
<div class="ui two column stackable grid">
|
||||
<div class="column">
|
||||
<table class="ui very basic table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>AI Enabled</strong></td>
|
||||
<td>
|
||||
{{if .AIConfig.Enabled}}
|
||||
<span class="ui small green label">On</span>
|
||||
{{else}}
|
||||
<span class="ui small red label">Off</span>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Provider</strong></td>
|
||||
<td>{{.AIConfig.DefaultProvider}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Model</strong></td>
|
||||
<td><code>{{.AIConfig.DefaultModel}}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Service URL</strong></td>
|
||||
<td><code>{{.AIConfig.ServiceURL}}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Rate Limit</strong></td>
|
||||
<td>{{.AIConfig.MaxOperationsPerHour}} ops/hr, {{.AIConfig.MaxTokensPerOperation}} tokens/op</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="column">
|
||||
<table class="ui very basic table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>Code Review</strong></td>
|
||||
<td>{{if .AIConfig.EnableCodeReview}}<span class="ui small green label">On</span>{{else}}<span class="ui small red label">Off</span>{{end}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Issue Triage</strong></td>
|
||||
<td>{{if .AIConfig.EnableIssueTriage}}<span class="ui small green label">On</span>{{else}}<span class="ui small red label">Off</span>{{end}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Doc Generation</strong></td>
|
||||
<td>{{if .AIConfig.EnableDocGen}}<span class="ui small green label">On</span>{{else}}<span class="ui small red label">Off</span>{{end}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Explain Code</strong></td>
|
||||
<td>{{if .AIConfig.EnableExplainCode}}<span class="ui small green label">On</span>{{else}}<span class="ui small red label">Off</span>{{end}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Chat</strong></td>
|
||||
<td>{{if .AIConfig.EnableChat}}<span class="ui small green label">On</span>{{else}}<span class="ui small red label">Off</span>{{end}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Auto-Respond</strong></td>
|
||||
<td>{{if .AIConfig.AllowAutoRespond}}<span class="ui small green label">On</span>{{else}}<span class="ui small red label">Off</span>{{end}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Auto-Review</strong></td>
|
||||
<td>{{if .AIConfig.AllowAutoReview}}<span class="ui small green label">On</span>{{else}}<span class="ui small red label">Off</span>{{end}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Agent Mode</strong></td>
|
||||
<td>{{if .AIConfig.AllowAgentMode}}<span class="ui small green label">On</span>{{else}}<span class="ui small red label">Off</span>{{end}}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .ProviderStatus}}
|
||||
<div class="ui divider"></div>
|
||||
<h5>Provider Status</h5>
|
||||
<div>
|
||||
{{range $provider, $status := .ProviderStatus}}
|
||||
<span class="ui label" style="margin: 2px; background-color: {{if eq $status "ok"}}#21ba45{{else if eq $status "healthy"}}#21ba45{{else}}#db2828{{end}}; color: white;">
|
||||
{{$provider}}: {{$status}}
|
||||
</span>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<!-- Status Breakdown -->
|
||||
<h4 class="ui attached header">
|
||||
{{ctx.Locale.Tr "admin.ai.stats"}}
|
||||
</h4>
|
||||
<div class="ui attached segment">
|
||||
<div style="display: flex; gap: 15px; flex-wrap: wrap;">
|
||||
<div class="ui small statistic" style="margin: 5px 15px;">
|
||||
<div class="value" style="color: #21ba45;">{{.Stats.SuccessCount}}</div>
|
||||
<div class="label">Success</div>
|
||||
</div>
|
||||
<div class="ui small statistic" style="margin: 5px 15px;">
|
||||
<div class="value" style="color: #db2828;">{{.Stats.FailedCount}}</div>
|
||||
<div class="label">Failed</div>
|
||||
</div>
|
||||
<div class="ui small statistic" style="margin: 5px 15px;">
|
||||
<div class="value" style="color: #f2711c;">{{.Stats.EscalatedCount}}</div>
|
||||
<div class="label">Escalated</div>
|
||||
</div>
|
||||
<div class="ui small statistic" style="margin: 5px 15px;">
|
||||
<div class="value" style="color: #2185d0;">{{.Stats.PendingCount}}</div>
|
||||
<div class="label">Pending</div>
|
||||
</div>
|
||||
{{range $tier, $count := .Stats.CountByTier}}
|
||||
<div class="ui small statistic" style="margin: 5px 15px;">
|
||||
<div class="value" style="color: #6435c9;">{{$count}}</div>
|
||||
<div class="label">Tier {{$tier}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Operations -->
|
||||
<h4 class="ui attached header">
|
||||
{{ctx.Locale.Tr "admin.ai.recent_operations"}}
|
||||
</h4>
|
||||
<div class="ui attached segment">
|
||||
<table class="ui celled striped table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Repo ID</th>
|
||||
<th>Operation</th>
|
||||
<th>Tier</th>
|
||||
<th>Status</th>
|
||||
<th>Duration</th>
|
||||
<th>Provider</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .RecentOps}}
|
||||
<tr>
|
||||
<td>{{DateUtils.TimeSince .CreatedUnix}}</td>
|
||||
<td>{{.RepoID}}</td>
|
||||
<td><code>{{.Operation}}</code></td>
|
||||
<td>
|
||||
<span class="ui small label" style="background-color: {{if eq .Tier 1}}#2185d0{{else}}#6435c9{{end}}; color: white;">
|
||||
Tier {{.Tier}}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{{if eq .Status "success"}}
|
||||
<span class="ui small green label">{{.Status}}</span>
|
||||
{{else if eq .Status "failed"}}
|
||||
<span class="ui small red label">{{.Status}}</span>
|
||||
{{else if eq .Status "escalated"}}
|
||||
<span class="ui small orange label">{{.Status}}</span>
|
||||
{{else}}
|
||||
<span class="ui small blue label">{{.Status}}</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td>{{.DurationMs}}ms</td>
|
||||
<td>
|
||||
{{if .Provider}}{{.Provider}}{{else}}-{{end}}
|
||||
{{if .Model}}<br><small style="color: var(--color-text-light);">{{.Model}}</small>{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr>
|
||||
<td colspan="7" class="center aligned">
|
||||
<i>No recent operations</i>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{{template "admin/layout_footer" .}}
|
||||
@@ -131,6 +131,9 @@
|
||||
</a>
|
||||
</div>
|
||||
</details>
|
||||
<a class="{{if .PageIsAdminAI}}active {{end}}item" href="{{AppSubUrl}}/-/admin/ai">
|
||||
{{ctx.Locale.Tr "admin.ai"}}
|
||||
</a>
|
||||
<a class="{{if .PageIsAdminAILearning}}active {{end}}item" href="{{AppSubUrl}}/-/admin/ai-learning">
|
||||
{{ctx.Locale.Tr "admin.ai_learning"}}
|
||||
</a>
|
||||
|
||||
Reference in New Issue
Block a user