mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
Overhaul: real metrics, Git integration UI, open-source hygiene, root cleanup
Backend: - GET /services/:id/metrics with real Docker container stats - DELETE /git/providers/:id and GET repo branches endpoint Frontend: - Service detail shows real telemetry or honest empty states - Live status distinguishes idle/polling/reconnecting - Git providers section in Settings; repo+branch pickers in Add Service - Real logo, compact Projects header, canvas fitView fix - index.css deduplicated (1725 -> 1384 lines) - Fixed smoke tests, lint errors, ToastProvider/context unification Repo hygiene: - Added LICENSE (MIT), CONTRIBUTING.md, SECURITY.md; rewrote README - Untracked .env.prod and data/letsencrypt secrets; fixed .gitignore - Removed legacy root src/internal/cmd and stale configs/docs/artifacts - Moved guides and design references under docs/ Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
355a97bab4
commit
9f3b196032
499 files changed
+4471
-94235
No files matched your search
+14
-183
@@ -1,6 +1,4 @@
|
||||
import { betterAuth } from 'better-auth';
|
||||
import { magicLink } from 'better-auth/plugins/magic-link';
|
||||
import { genericOAuth } from 'better-auth/plugins/generic-oauth';
|
||||
import { Pool } from 'pg';
|
||||
import { PostgresDialect } from 'kysely';
|
||||
import { getMigrations } from 'better-auth/db/migration';
|
||||
@@ -50,41 +48,22 @@ const database = new PostgresDialect({
|
||||
|
||||
const githubClientId = env.GITHUB_CLIENT_ID?.trim() || '';
|
||||
const githubClientSecret = env.GITHUB_CLIENT_SECRET?.trim() || '';
|
||||
|
||||
const gitlabClientId = env.GITLAB_CLIENT_ID?.trim() || 'PLACEHOLDER_GITLAB_CLIENT_ID';
|
||||
const gitlabClientSecret = env.GITLAB_CLIENT_SECRET?.trim() || 'PLACEHOLDER_GITLAB_CLIENT_SECRET';
|
||||
const gitlabAuthorizeUrl =
|
||||
env.GITLAB_OAUTH_AUTHORIZE_URL?.trim() || 'https://gitlab.com/oauth/authorize';
|
||||
const gitlabTokenUrl = env.GITLAB_OAUTH_TOKEN_URL?.trim() || 'https://gitlab.com/oauth/token';
|
||||
const gitlabUserUrl = env.GITLAB_OAUTH_USERINFO_URL?.trim() || 'https://gitlab.com/api/v4/user';
|
||||
|
||||
const giteaClientId = env.GITEA_CLIENT_ID?.trim() || 'PLACEHOLDER_GITEA_CLIENT_ID';
|
||||
const giteaClientSecret = env.GITEA_CLIENT_SECRET?.trim() || 'PLACEHOLDER_GITEA_CLIENT_SECRET';
|
||||
const giteaAuthorizeUrl =
|
||||
env.GITEA_OAUTH_AUTHORIZE_URL?.trim() ||
|
||||
'https://gitea.example.com/login/oauth/authorize';
|
||||
const giteaTokenUrl =
|
||||
env.GITEA_OAUTH_TOKEN_URL?.trim() ||
|
||||
'https://gitea.example.com/login/oauth/access_token';
|
||||
const giteaUserUrl = env.GITEA_OAUTH_USERINFO_URL?.trim() || 'https://gitea.example.com/api/v1/user';
|
||||
|
||||
const bitbucketClientId = env.BITBUCKET_CLIENT_ID?.trim() || 'PLACEHOLDER_BITBUCKET_CLIENT_ID';
|
||||
const bitbucketClientSecret =
|
||||
env.BITBUCKET_CLIENT_SECRET?.trim() || 'PLACEHOLDER_BITBUCKET_CLIENT_SECRET';
|
||||
const bitbucketAuthorizeUrl =
|
||||
env.BITBUCKET_OAUTH_AUTHORIZE_URL?.trim() || 'https://bitbucket.org/site/oauth2/authorize';
|
||||
const bitbucketTokenUrl =
|
||||
env.BITBUCKET_OAUTH_TOKEN_URL?.trim() || 'https://bitbucket.org/site/oauth2/access_token';
|
||||
const bitbucketUserUrl =
|
||||
env.BITBUCKET_OAUTH_USERINFO_URL?.trim() || 'https://api.bitbucket.org/2.0/user';
|
||||
const bitbucketEmailsUrl =
|
||||
env.BITBUCKET_OAUTH_EMAILS_URL?.trim() || 'https://api.bitbucket.org/2.0/user/emails';
|
||||
const googleClientId = env.GOOGLE_CLIENT_ID?.trim() || '';
|
||||
const googleClientSecret = env.GOOGLE_CLIENT_SECRET?.trim() || '';
|
||||
|
||||
const socialProviders = {};
|
||||
if (githubClientId && githubClientSecret) {
|
||||
socialProviders.github = {
|
||||
clientId: githubClientId,
|
||||
clientSecret: githubClientSecret,
|
||||
disableImplicitSignUp: true,
|
||||
};
|
||||
}
|
||||
if (googleClientId && googleClientSecret) {
|
||||
socialProviders.google = {
|
||||
clientId: googleClientId,
|
||||
clientSecret: googleClientSecret,
|
||||
disableImplicitSignUp: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -97,6 +76,10 @@ const trustedOrigins = [
|
||||
'http://localhost:3000',
|
||||
'http://localhost:8082',
|
||||
'http://localhost:3001',
|
||||
'http://localhost:5173',
|
||||
'http://localhost:5174',
|
||||
'http://127.0.0.1:5173',
|
||||
'http://127.0.0.1:5174',
|
||||
]
|
||||
.map((origin) => (origin || '').trim())
|
||||
.filter(Boolean);
|
||||
@@ -130,158 +113,6 @@ export const auth = betterAuth({
|
||||
maxPasswordLength: 128,
|
||||
},
|
||||
socialProviders: Object.keys(socialProviders).length > 0 ? socialProviders : undefined,
|
||||
plugins: [
|
||||
magicLink({
|
||||
sendMagicLink: async ({ email, url, token, metadata }) => {
|
||||
const payload = {
|
||||
email,
|
||||
url,
|
||||
token,
|
||||
metadata,
|
||||
};
|
||||
console.log('[better-auth] magic-link', JSON.stringify(payload));
|
||||
},
|
||||
disableSignUp: false,
|
||||
}),
|
||||
genericOAuth({
|
||||
config: [
|
||||
{
|
||||
providerId: 'gitlab',
|
||||
clientId: gitlabClientId,
|
||||
clientSecret: gitlabClientSecret,
|
||||
authorizationUrl: gitlabAuthorizeUrl,
|
||||
tokenUrl: gitlabTokenUrl,
|
||||
userInfoUrl: gitlabUserUrl,
|
||||
scopes: ['read_user', 'read_api'],
|
||||
getUserInfo: async (tokens) => {
|
||||
if (!tokens.accessToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const response = await fetch(gitlabUserUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${tokens.accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const profile = await response.json();
|
||||
if (!profile || (!profile.id && !profile.username)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: profile.id || profile.username,
|
||||
email: profile.email || null,
|
||||
name: profile.name || profile.username || 'GitLab User',
|
||||
image: profile.avatar_url || null,
|
||||
emailVerified: Boolean(profile.email),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
providerId: 'gitea',
|
||||
clientId: giteaClientId,
|
||||
clientSecret: giteaClientSecret,
|
||||
authorizationUrl: giteaAuthorizeUrl,
|
||||
tokenUrl: giteaTokenUrl,
|
||||
userInfoUrl: giteaUserUrl,
|
||||
scopes: ['read:user', 'read:repository'],
|
||||
getUserInfo: async (tokens) => {
|
||||
if (!tokens.accessToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const response = await fetch(giteaUserUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `token ${tokens.accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const profile = await response.json();
|
||||
if (!profile || (!profile.id && !profile.login)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: profile.id || profile.login,
|
||||
email: profile.email || null,
|
||||
name: profile.full_name || profile.login || 'Gitea User',
|
||||
image: profile.avatar_url || null,
|
||||
emailVerified: Boolean(profile.email),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
providerId: 'bitbucket',
|
||||
clientId: bitbucketClientId,
|
||||
clientSecret: bitbucketClientSecret,
|
||||
authorizationUrl: bitbucketAuthorizeUrl,
|
||||
tokenUrl: bitbucketTokenUrl,
|
||||
userInfoUrl: bitbucketUserUrl,
|
||||
scopes: ['account', 'email', 'repository'],
|
||||
getUserInfo: async (tokens) => {
|
||||
if (!tokens.accessToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const userResponse = await fetch(bitbucketUserUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${tokens.accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!userResponse.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = await userResponse.json();
|
||||
if (!user || (!user.account_id && !user.username && !user.nickname)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let primaryEmail = null;
|
||||
const emailsResponse = await fetch(bitbucketEmailsUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${tokens.accessToken}`,
|
||||
},
|
||||
});
|
||||
if (emailsResponse.ok) {
|
||||
const emailsPayload = await emailsResponse.json();
|
||||
const values = Array.isArray(emailsPayload?.values) ? emailsPayload.values : [];
|
||||
const primary = values.find((entry) => entry?.is_primary) || values[0];
|
||||
if (primary && typeof primary.email === 'string') {
|
||||
primaryEmail = primary.email;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.account_id || user.username || user.nickname,
|
||||
email: primaryEmail,
|
||||
name: user.display_name || user.nickname || 'Bitbucket User',
|
||||
image: user?.links?.avatar?.href || null,
|
||||
emailVerified: Boolean(primaryEmail),
|
||||
};
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
export async function runAuthMigrations() {
|
||||
|
||||
@@ -0,0 +1,566 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
type agentConfig struct {
|
||||
APIURL string
|
||||
Token string
|
||||
Name string
|
||||
Hostname string
|
||||
IP string
|
||||
Port int
|
||||
Interval time.Duration
|
||||
}
|
||||
|
||||
type capabilities struct {
|
||||
ContainerRuntimes []string `json:"container_runtimes"`
|
||||
SupportedArchitectures []string `json:"supported_architectures"`
|
||||
MaxContainers int `json:"max_containers"`
|
||||
StorageDriver string `json:"storage_driver"`
|
||||
NetworkPlugins []string `json:"network_plugins"`
|
||||
Features []string `json:"features"`
|
||||
}
|
||||
|
||||
type nodeResources struct {
|
||||
CPU cpuResources `json:"cpu"`
|
||||
Memory memoryResources `json:"memory"`
|
||||
Storage storageResources `json:"storage"`
|
||||
Network networkResources `json:"network"`
|
||||
}
|
||||
|
||||
type cpuResources struct {
|
||||
Cores int `json:"cores"`
|
||||
Allocation float64 `json:"allocation"`
|
||||
Usage float64 `json:"usage"`
|
||||
}
|
||||
|
||||
type memoryResources struct {
|
||||
Total int `json:"total"`
|
||||
Allocated int `json:"allocated"`
|
||||
Used int `json:"used"`
|
||||
Available int `json:"available"`
|
||||
}
|
||||
|
||||
type storageResources struct {
|
||||
Total int `json:"total"`
|
||||
Allocated int `json:"allocated"`
|
||||
Used int `json:"used"`
|
||||
Available int `json:"available"`
|
||||
}
|
||||
|
||||
type networkResources struct {
|
||||
Interfaces []networkInterface `json:"interfaces"`
|
||||
Bandwidth bandwidthInfo `json:"bandwidth"`
|
||||
}
|
||||
|
||||
type networkInterface struct {
|
||||
Name string `json:"name"`
|
||||
IPAddress string `json:"ip_address"`
|
||||
MACAddress string `json:"mac_address"`
|
||||
Speed int `json:"speed"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type bandwidthInfo struct {
|
||||
Inbound int `json:"inbound"`
|
||||
Outbound int `json:"outbound"`
|
||||
}
|
||||
|
||||
type systemLoad struct {
|
||||
Load1M float64 `json:"load_1m"`
|
||||
Load5M float64 `json:"load_5m"`
|
||||
Load15M float64 `json:"load_15m"`
|
||||
}
|
||||
|
||||
type command struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Payload map[string]interface{} `json:"payload"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type commandList struct {
|
||||
Commands []command `json:"commands"`
|
||||
}
|
||||
|
||||
type registerResponse struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 20 * time.Second}
|
||||
agentID, err := register(context.Background(), client, cfg)
|
||||
if err != nil {
|
||||
log.Fatalf("register agent: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("containr agent connected: id=%s api=%s", agentID, cfg.APIURL)
|
||||
ticker := time.NewTicker(cfg.Interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
if err := sendHeartbeat(context.Background(), client, cfg, agentID); err != nil {
|
||||
log.Printf("heartbeat failed: %v", err)
|
||||
}
|
||||
if err := processCommands(context.Background(), client, cfg, agentID); err != nil {
|
||||
log.Printf("command poll failed: %v", err)
|
||||
}
|
||||
<-ticker.C
|
||||
}
|
||||
}
|
||||
|
||||
func loadConfig() (agentConfig, error) {
|
||||
hostname, _ := os.Hostname()
|
||||
apiURL := strings.TrimRight(firstEnv("CONTAINR_API_URL", "http://localhost:8082"), "/")
|
||||
apiURL = strings.TrimSuffix(strings.TrimSuffix(apiURL, "/api/v1"), "/api")
|
||||
token := strings.TrimSpace(os.Getenv("CONTAINR_AGENT_AUTH_TOKEN"))
|
||||
if token == "" {
|
||||
token = strings.TrimSpace(os.Getenv("CONTAINR_AGENT_TOKEN"))
|
||||
}
|
||||
if token == "" {
|
||||
return agentConfig{}, errors.New("set CONTAINR_AGENT_AUTH_TOKEN")
|
||||
}
|
||||
|
||||
intervalSeconds, _ := strconv.Atoi(firstEnv("CONTAINR_AGENT_INTERVAL_SECONDS", "15"))
|
||||
if intervalSeconds < 5 {
|
||||
intervalSeconds = 5
|
||||
}
|
||||
port, _ := strconv.Atoi(firstEnv("CONTAINR_AGENT_PORT", "9090"))
|
||||
|
||||
return agentConfig{
|
||||
APIURL: apiURL,
|
||||
Token: token,
|
||||
Name: firstEnv("CONTAINR_AGENT_NAME", hostname),
|
||||
Hostname: firstEnv("CONTAINR_AGENT_HOSTNAME", hostname),
|
||||
IP: firstEnv("CONTAINR_AGENT_IP", detectOutboundIP()),
|
||||
Port: port,
|
||||
Interval: time.Duration(intervalSeconds) * time.Second,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func register(ctx context.Context, client *http.Client, cfg agentConfig) (string, error) {
|
||||
payload := map[string]interface{}{
|
||||
"name": cfg.Name,
|
||||
"hostname": cfg.Hostname,
|
||||
"ip_address": cfg.IP,
|
||||
"port": cfg.Port,
|
||||
"capabilities": detectCapabilities(),
|
||||
}
|
||||
var response registerResponse
|
||||
if err := postJSON(ctx, client, cfg, "/api/agents/register", payload, &response); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if response.AgentID == "" {
|
||||
return "", errors.New("register response missing agent_id")
|
||||
}
|
||||
return response.AgentID, nil
|
||||
}
|
||||
|
||||
func sendHeartbeat(ctx context.Context, client *http.Client, cfg agentConfig, agentID string) error {
|
||||
resources := collectResources(cfg.IP)
|
||||
payload := map[string]interface{}{
|
||||
"node_agent_id": agentID,
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||
"status": agentStatus(),
|
||||
"resources": resources,
|
||||
"container_count": dockerContainerCount(),
|
||||
"system_load": readSystemLoad(),
|
||||
"uptime": readSystemUptime(),
|
||||
"version": firstEnv("CONTAINR_AGENT_VERSION", "dev"),
|
||||
}
|
||||
return postJSON(ctx, client, cfg, "/api/agents/heartbeat", payload, nil)
|
||||
}
|
||||
|
||||
func processCommands(ctx context.Context, client *http.Client, cfg agentConfig, agentID string) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/api/agents/%s/commands", cfg.APIURL, agentID), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("X-Containr-Agent-Token", cfg.Token)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("command poll status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var list commandList
|
||||
if err := json.NewDecoder(resp.Body).Decode(&list); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, item := range list.Commands {
|
||||
result, runErr := executeCommand(item)
|
||||
status := "completed"
|
||||
errText := ""
|
||||
if runErr != nil {
|
||||
status = "failed"
|
||||
errText = runErr.Error()
|
||||
}
|
||||
payload := map[string]string{
|
||||
"status": status,
|
||||
"result": result,
|
||||
"error": errText,
|
||||
}
|
||||
path := fmt.Sprintf("/api/agents/%s/commands/%s/result", agentID, item.ID)
|
||||
if err := postJSON(ctx, client, cfg, path, payload, nil); err != nil {
|
||||
log.Printf("report command result failed: command=%s err=%v", item.ID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeCommand(item command) (string, error) {
|
||||
switch item.Type {
|
||||
case "create_container":
|
||||
return createContainer(item.Payload)
|
||||
case "start_container":
|
||||
return runDocker("start", commandTarget(item.Payload))
|
||||
case "stop_container":
|
||||
return runDocker("stop", commandTarget(item.Payload))
|
||||
case "restart_container":
|
||||
return runDocker("restart", commandTarget(item.Payload))
|
||||
case "remove_container":
|
||||
return runDocker("rm", "-f", commandTarget(item.Payload))
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported command type %q", item.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func createContainer(payload map[string]interface{}) (string, error) {
|
||||
container, ok := payload["container"].(map[string]interface{})
|
||||
if !ok {
|
||||
return "", errors.New("missing container payload")
|
||||
}
|
||||
|
||||
name := stringValue(container["name"])
|
||||
image := stringValue(container["image"])
|
||||
if name == "" || image == "" {
|
||||
return "", errors.New("container name and image are required")
|
||||
}
|
||||
|
||||
args := []string{"run", "-d", "--name", name, "--restart", "unless-stopped"}
|
||||
if env, ok := container["environment"].(map[string]interface{}); ok {
|
||||
for key, value := range env {
|
||||
args = append(args, "-e", fmt.Sprintf("%s=%v", key, value))
|
||||
}
|
||||
}
|
||||
if ports, ok := container["ports"].([]interface{}); ok {
|
||||
for _, rawPort := range ports {
|
||||
port, ok := rawPort.(map[string]interface{})
|
||||
if !ok || !boolValue(port["published"]) {
|
||||
continue
|
||||
}
|
||||
hostPort := intValue(port["host_port"])
|
||||
containerPort := intValue(port["container_port"])
|
||||
protocol := firstString(stringValue(port["protocol"]), "tcp")
|
||||
if hostPort > 0 && containerPort > 0 {
|
||||
args = append(args, "-p", fmt.Sprintf("%d:%d/%s", hostPort, containerPort, protocol))
|
||||
}
|
||||
}
|
||||
}
|
||||
if volumes, ok := container["volumes"].([]interface{}); ok {
|
||||
for _, rawVolume := range volumes {
|
||||
volume, ok := rawVolume.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
source := stringValue(volume["source"])
|
||||
target := stringValue(volume["target"])
|
||||
if source != "" && target != "" {
|
||||
mount := source + ":" + target
|
||||
if boolValue(volume["read_only"]) {
|
||||
mount += ":ro"
|
||||
}
|
||||
args = append(args, "-v", mount)
|
||||
}
|
||||
}
|
||||
}
|
||||
args = append(args, image)
|
||||
return runDocker(args...)
|
||||
}
|
||||
|
||||
func commandTarget(payload map[string]interface{}) string {
|
||||
for _, key := range []string{"container_name", "docker_name", "name", "container_id"} {
|
||||
if value := stringValue(payload[key]); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func postJSON(ctx context.Context, client *http.Client, cfg agentConfig, path string, payload interface{}, out interface{}) error {
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, cfg.APIURL+path, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Containr-Agent-Token", cfg.Token)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("request %s failed with status %d", path, resp.StatusCode)
|
||||
}
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
}
|
||||
|
||||
func detectCapabilities() capabilities {
|
||||
runtimes := []string{}
|
||||
if dockerAvailable() {
|
||||
runtimes = append(runtimes, "docker")
|
||||
}
|
||||
return capabilities{
|
||||
ContainerRuntimes: runtimes,
|
||||
SupportedArchitectures: []string{runtime.GOARCH},
|
||||
MaxContainers: 100,
|
||||
StorageDriver: dockerStorageDriver(),
|
||||
NetworkPlugins: []string{"bridge"},
|
||||
Features: []string{"host-metrics", "heartbeats", "command-poll", "docker-lifecycle"},
|
||||
}
|
||||
}
|
||||
|
||||
func collectResources(ip string) nodeResources {
|
||||
mem := readMemory()
|
||||
storage := readStorage("/")
|
||||
return nodeResources{
|
||||
CPU: cpuResources{
|
||||
Cores: runtime.NumCPU(),
|
||||
Allocation: 0,
|
||||
Usage: readCPULoadPercent(),
|
||||
},
|
||||
Memory: mem,
|
||||
Storage: storage,
|
||||
Network: networkResources{
|
||||
Interfaces: []networkInterface{{
|
||||
Name: "primary",
|
||||
IPAddress: ip,
|
||||
Speed: 0,
|
||||
Status: "up",
|
||||
}},
|
||||
Bandwidth: bandwidthInfo{},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func readMemory() memoryResources {
|
||||
values := map[string]int{}
|
||||
data, err := os.ReadFile("/proc/meminfo")
|
||||
if err != nil {
|
||||
return memoryResources{}
|
||||
}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
value, err := strconv.Atoi(fields[1])
|
||||
if err == nil {
|
||||
values[strings.TrimSuffix(fields[0], ":")] = value * 1024
|
||||
}
|
||||
}
|
||||
total := values["MemTotal"]
|
||||
available := values["MemAvailable"]
|
||||
used := total - available
|
||||
if used < 0 {
|
||||
used = 0
|
||||
}
|
||||
return memoryResources{Total: total, Used: used, Available: available}
|
||||
}
|
||||
|
||||
func readStorage(path string) storageResources {
|
||||
var stat syscall.Statfs_t
|
||||
if err := syscall.Statfs(path, &stat); err != nil {
|
||||
return storageResources{}
|
||||
}
|
||||
total := int(stat.Blocks * uint64(stat.Bsize))
|
||||
available := int(stat.Bavail * uint64(stat.Bsize))
|
||||
used := total - available
|
||||
if used < 0 {
|
||||
used = 0
|
||||
}
|
||||
return storageResources{Total: total, Used: used, Available: available}
|
||||
}
|
||||
|
||||
func readSystemLoad() systemLoad {
|
||||
data, err := os.ReadFile("/proc/loadavg")
|
||||
if err != nil {
|
||||
return systemLoad{}
|
||||
}
|
||||
fields := strings.Fields(string(data))
|
||||
if len(fields) < 3 {
|
||||
return systemLoad{}
|
||||
}
|
||||
load1, _ := strconv.ParseFloat(fields[0], 64)
|
||||
load5, _ := strconv.ParseFloat(fields[1], 64)
|
||||
load15, _ := strconv.ParseFloat(fields[2], 64)
|
||||
return systemLoad{Load1M: load1, Load5M: load5, Load15M: load15}
|
||||
}
|
||||
|
||||
func readSystemUptime() int64 {
|
||||
data, err := os.ReadFile("/proc/uptime")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
fields := strings.Fields(string(data))
|
||||
if len(fields) == 0 {
|
||||
return 0
|
||||
}
|
||||
uptime, _ := strconv.ParseFloat(fields[0], 64)
|
||||
return int64(uptime)
|
||||
}
|
||||
|
||||
func readCPULoadPercent() float64 {
|
||||
load := readSystemLoad()
|
||||
cores := runtime.NumCPU()
|
||||
if cores == 0 {
|
||||
return 0
|
||||
}
|
||||
percent := load.Load1M / float64(cores) * 100
|
||||
if percent < 0 {
|
||||
return 0
|
||||
}
|
||||
if percent > 100 {
|
||||
return 100
|
||||
}
|
||||
return percent
|
||||
}
|
||||
|
||||
func runDocker(args ...string) (string, error) {
|
||||
if len(args) == 0 || args[len(args)-1] == "" {
|
||||
return "", errors.New("missing docker target")
|
||||
}
|
||||
cmd := exec.Command("docker", args...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
return strings.TrimSpace(string(output)), err
|
||||
}
|
||||
|
||||
func dockerAvailable() bool {
|
||||
_, err := exec.LookPath("docker")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
cmd := exec.Command("docker", "info", "--format", "{{.ServerVersion}}")
|
||||
return cmd.Run() == nil
|
||||
}
|
||||
|
||||
func dockerStorageDriver() string {
|
||||
out, err := exec.Command("docker", "info", "--format", "{{.Driver}}").Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
func dockerContainerCount() int {
|
||||
out, err := exec.Command("docker", "ps", "-q").Output()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
count := 0
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
if strings.TrimSpace(line) != "" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func agentStatus() string {
|
||||
if dockerAvailable() {
|
||||
return "online"
|
||||
}
|
||||
return "degraded"
|
||||
}
|
||||
|
||||
func detectOutboundIP() string {
|
||||
conn, err := net.DialTimeout("udp", "8.8.8.8:80", 2*time.Second)
|
||||
if err != nil {
|
||||
return "127.0.0.1"
|
||||
}
|
||||
defer conn.Close()
|
||||
if addr, ok := conn.LocalAddr().(*net.UDPAddr); ok {
|
||||
return addr.IP.String()
|
||||
}
|
||||
return "127.0.0.1"
|
||||
}
|
||||
|
||||
func firstEnv(key, fallback string) string {
|
||||
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func firstString(value, fallback string) string {
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func stringValue(value interface{}) string {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
return typed
|
||||
case fmt.Stringer:
|
||||
return typed.String()
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func intValue(value interface{}) int {
|
||||
switch typed := value.(type) {
|
||||
case int:
|
||||
return typed
|
||||
case int64:
|
||||
return int(typed)
|
||||
case float64:
|
||||
return int(typed)
|
||||
case json.Number:
|
||||
out, _ := typed.Int64()
|
||||
return int(out)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func boolValue(value interface{}) bool {
|
||||
typed, _ := value.(bool)
|
||||
return typed
|
||||
}
|
||||
@@ -48,8 +48,8 @@ func main() {
|
||||
log.Println("AUTO_MIGRATE disabled; skipping startup migrations")
|
||||
}
|
||||
|
||||
// Seed demo data in development (or when explicitly requested).
|
||||
if cfg.IsDevelopment() || cfg.SeedDataOnStart {
|
||||
// Seed demo data only when explicitly requested.
|
||||
if cfg.SeedDataOnStart {
|
||||
if err := db.SeedData(); err != nil {
|
||||
log.Printf("Warning: Failed to seed data: %v", err)
|
||||
}
|
||||
|
||||
@@ -171,6 +171,7 @@ type AgentCommand struct {
|
||||
// AgentHeartbeat represents a heartbeat message from an agent
|
||||
type AgentHeartbeat struct {
|
||||
NodeAgentID string `json:"node_agent_id"`
|
||||
AuthToken string `json:"auth_token,omitempty"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Status string `json:"status"`
|
||||
Resources NodeResources `json:"resources"`
|
||||
@@ -193,6 +194,10 @@ type AgentHeartbeatRecord struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (AgentHeartbeatRecord) TableName() string {
|
||||
return "agent_heartbeats"
|
||||
}
|
||||
|
||||
type SystemLoad struct {
|
||||
Load1M float64 `json:"load_1m"`
|
||||
Load5M float64 `json:"load_5m"`
|
||||
@@ -216,7 +221,7 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
|
||||
IPAddress string `json:"ip_address" binding:"required"`
|
||||
Port int `json:"port" binding:"required"`
|
||||
Capabilities AgentCapabilities `json:"capabilities" binding:"required"`
|
||||
AuthToken string `json:"auth_token" binding:"required"`
|
||||
AuthToken string `json:"auth_token"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -224,7 +229,8 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if !isValidAgentAuthToken(req.AuthToken) {
|
||||
authToken := firstNonEmpty(req.AuthToken, agentAuthTokenFromRequest(c))
|
||||
if !isValidAgentAuthToken(authToken) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid auth token"})
|
||||
return
|
||||
}
|
||||
@@ -246,7 +252,7 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"agent_id": existingAgent.ID,
|
||||
"auth_token": req.AuthToken,
|
||||
"auth_token": authToken,
|
||||
"status": "updated",
|
||||
})
|
||||
return
|
||||
@@ -308,7 +314,7 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"agent_id": agent.ID,
|
||||
"auth_token": req.AuthToken,
|
||||
"auth_token": authToken,
|
||||
"status": "registered",
|
||||
})
|
||||
}
|
||||
@@ -388,6 +394,11 @@ func (h *NodeAgentHandler) SendHeartbeat(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
authToken := firstNonEmpty(heartbeat.AuthToken, agentAuthTokenFromRequest(c))
|
||||
if !isValidAgentAuthToken(authToken) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid auth token"})
|
||||
return
|
||||
}
|
||||
if heartbeat.Timestamp.IsZero() {
|
||||
heartbeat.Timestamp = time.Now()
|
||||
}
|
||||
@@ -436,6 +447,118 @@ func (h *NodeAgentHandler) SendHeartbeat(c *gin.Context) {
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
// GetPendingCommandsForAgent exposes queued commands to token-authenticated node agents.
|
||||
func (h *NodeAgentHandler) GetPendingCommandsForAgent(c *gin.Context) {
|
||||
if !isValidAgentAuthToken(agentAuthTokenFromRequest(c)) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid auth token"})
|
||||
return
|
||||
}
|
||||
|
||||
agentID := c.Param("id")
|
||||
var commands []AgentCommand
|
||||
if err := h.db.
|
||||
Where("node_agent_id = ? AND status = ?", agentID, "pending").
|
||||
Order("created_at ASC").
|
||||
Limit(25).
|
||||
Find(&commands).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch commands"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"commands": commands})
|
||||
}
|
||||
|
||||
// CompleteCommand lets a node agent report command completion or failure.
|
||||
func (h *NodeAgentHandler) CompleteCommand(c *gin.Context) {
|
||||
if !isValidAgentAuthToken(agentAuthTokenFromRequest(c)) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid auth token"})
|
||||
return
|
||||
}
|
||||
|
||||
agentID := c.Param("id")
|
||||
commandID := c.Param("commandId")
|
||||
var req struct {
|
||||
Status string `json:"status" binding:"required"`
|
||||
Result string `json:"result"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.Status != "completed" && req.Status != "failed" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "status must be completed or failed"})
|
||||
return
|
||||
}
|
||||
|
||||
var command AgentCommand
|
||||
if err := h.db.First(&command, "id = ? AND node_agent_id = ?", commandID, agentID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Command not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch command"})
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
command.Status = req.Status
|
||||
command.UpdatedAt = now
|
||||
command.CompletedAt = &now
|
||||
if req.Result != "" {
|
||||
command.Result = &req.Result
|
||||
}
|
||||
if req.Error != "" {
|
||||
command.Error = &req.Error
|
||||
}
|
||||
|
||||
if err := h.db.Save(&command).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update command"})
|
||||
return
|
||||
}
|
||||
|
||||
if command.ContainerID != nil {
|
||||
h.updateContainerStatusAfterCommand(command)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"command": command})
|
||||
}
|
||||
|
||||
func (h *NodeAgentHandler) updateContainerStatusAfterCommand(command AgentCommand) {
|
||||
var container ContainerInstance
|
||||
if err := h.db.First(&container, "id = ?", *command.ContainerID).Error; err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if command.Status == "failed" {
|
||||
errorMessage := ""
|
||||
if command.Error != nil {
|
||||
errorMessage = *command.Error
|
||||
}
|
||||
container.Status.State = "error"
|
||||
container.Status.Error = &errorMessage
|
||||
_ = h.db.Save(&container).Error
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
switch command.Type {
|
||||
case "create_container", "start_container", "restart_container":
|
||||
container.Status.State = "running"
|
||||
container.Status.Health = "unknown"
|
||||
container.Status.StartedAt = &now
|
||||
container.Status.Error = nil
|
||||
case "stop_container":
|
||||
container.Status.State = "stopped"
|
||||
container.Status.FinishedAt = &now
|
||||
case "remove_container":
|
||||
container.Status.State = "removed"
|
||||
container.Status.FinishedAt = &now
|
||||
}
|
||||
container.UpdatedAt = now
|
||||
_ = h.db.Save(&container).Error
|
||||
}
|
||||
|
||||
// GetAgentContainers returns containers running on a specific agent
|
||||
func (h *NodeAgentHandler) GetAgentContainers(c *gin.Context) {
|
||||
agentID := c.Param("id")
|
||||
@@ -634,7 +757,9 @@ func (h *NodeAgentHandler) ContainerAction(c *gin.Context) {
|
||||
NodeAgentID: agentID,
|
||||
ContainerID: &container.ID,
|
||||
Payload: map[string]interface{}{
|
||||
"container_id": containerID,
|
||||
"container_id": containerID,
|
||||
"container_name": container.Name,
|
||||
"docker_name": container.Name,
|
||||
},
|
||||
Status: "pending",
|
||||
CreatedAt: time.Now(),
|
||||
@@ -757,6 +882,19 @@ func isValidAgentAuthToken(token string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func agentAuthTokenFromRequest(c *gin.Context) string {
|
||||
if token := strings.TrimSpace(c.GetHeader("X-Containr-Agent-Token")); token != "" {
|
||||
return token
|
||||
}
|
||||
if authHeader := strings.TrimSpace(c.GetHeader("Authorization")); authHeader != "" {
|
||||
const bearerPrefix = "Bearer "
|
||||
if strings.HasPrefix(authHeader, bearerPrefix) {
|
||||
return strings.TrimSpace(strings.TrimPrefix(authHeader, bearerPrefix))
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(c.Query("auth_token"))
|
||||
}
|
||||
|
||||
func configuredAgentAuthTokens() []string {
|
||||
candidateCSV := strings.TrimSpace(os.Getenv("CONTAINR_AGENT_AUTH_TOKENS"))
|
||||
if candidateCSV != "" {
|
||||
@@ -806,16 +944,25 @@ func maxInt(a, b int) int {
|
||||
return b
|
||||
}
|
||||
|
||||
// SetupRoutes registers the agent routes
|
||||
func (h *NodeAgentHandler) SetupRoutes(router *gin.RouterGroup) {
|
||||
// SetupPublicRoutes registers token-authenticated agent ingestion routes.
|
||||
func (h *NodeAgentHandler) SetupPublicRoutes(router *gin.RouterGroup) {
|
||||
agents := router.Group("/agents")
|
||||
{
|
||||
agents.POST("/register", h.RegisterAgent)
|
||||
agents.POST("/heartbeat", h.SendHeartbeat)
|
||||
agents.GET("/:id/commands", h.GetPendingCommandsForAgent)
|
||||
agents.POST("/:id/commands/:commandId/result", h.CompleteCommand)
|
||||
}
|
||||
}
|
||||
|
||||
// SetupRoutes registers authenticated dashboard/control agent routes.
|
||||
func (h *NodeAgentHandler) SetupRoutes(router *gin.RouterGroup) {
|
||||
agents := router.Group("/agents")
|
||||
{
|
||||
agents.GET("", h.GetAgents)
|
||||
agents.GET("/:id", h.GetAgent)
|
||||
agents.PUT("/:id", h.UpdateAgent)
|
||||
agents.DELETE("/:id", h.DeleteAgent)
|
||||
agents.POST("/heartbeat", h.SendHeartbeat)
|
||||
|
||||
agents.GET("/:id/containers", h.GetAgentContainers)
|
||||
agents.POST("/:id/containers", h.CreateContainer)
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"containr/internal/database"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
@@ -18,10 +26,16 @@ type LoginRequest struct {
|
||||
|
||||
type RegisterRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required,min=6"`
|
||||
Password string `json:"password" binding:"required,min=8,max=128"`
|
||||
Name string `json:"name" binding:"required,min=2"`
|
||||
}
|
||||
|
||||
type ManualUserCreateRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required,min=8,max=128"`
|
||||
Name string `json:"name" binding:"required,min=2,max=255"`
|
||||
}
|
||||
|
||||
type AuthResponse struct {
|
||||
Token string `json:"token"`
|
||||
User interface{} `json:"user"`
|
||||
@@ -55,17 +69,28 @@ func handleLogin(c *gin.Context) {
|
||||
`, req.Email).Scan(&user.ID, &user.Email, &hashedPassword, &user.Name, &user.AvatarURL, &user.CreatedAt)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
|
||||
return
|
||||
authUser, ok := verifyBetterAuthCredentials(c, req.Email, req.Password)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
|
||||
return
|
||||
}
|
||||
|
||||
user, err = createLocalUserFromBetterAuth(db, authUser)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create local user"})
|
||||
return
|
||||
}
|
||||
} else if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check password
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(req.Password)); err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
|
||||
return
|
||||
} else {
|
||||
// Check password
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(req.Password)); err != nil {
|
||||
if _, ok := verifyBetterAuthCredentials(c, req.Email, req.Password); !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate JWT token
|
||||
@@ -81,6 +106,154 @@ func handleLogin(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func handleAuthBootstrap(c *gin.Context) {
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
|
||||
count, err := countLocalUsers(db)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to inspect auth bootstrap state"})
|
||||
return
|
||||
}
|
||||
|
||||
mode := "login"
|
||||
if count == 0 {
|
||||
mode = "register"
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"has_users": count > 0,
|
||||
"user_count": count,
|
||||
"mode": mode,
|
||||
})
|
||||
}
|
||||
|
||||
type betterAuthLoginUser struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Image string `json:"image"`
|
||||
}
|
||||
|
||||
type betterAuthLoginResponse struct {
|
||||
User betterAuthLoginUser `json:"user"`
|
||||
}
|
||||
|
||||
func verifyBetterAuthCredentials(c *gin.Context, email string, password string) (betterAuthLoginUser, bool) {
|
||||
endpoint, err := betterAuthSignInURL()
|
||||
if err != nil {
|
||||
log.Printf("Better Auth fallback disabled: %v", err)
|
||||
return betterAuthLoginUser{}, false
|
||||
}
|
||||
|
||||
body, err := json.Marshal(gin.H{
|
||||
"email": strings.TrimSpace(email),
|
||||
"password": password,
|
||||
})
|
||||
if err != nil {
|
||||
return betterAuthLoginUser{}, false
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return betterAuthLoginUser{}, false
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Accept", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
log.Printf("Better Auth fallback failed: %v", err)
|
||||
return betterAuthLoginUser{}, false
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
|
||||
return betterAuthLoginUser{}, false
|
||||
}
|
||||
|
||||
var payload betterAuthLoginResponse
|
||||
if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
|
||||
log.Printf("Better Auth fallback response decode failed: %v", err)
|
||||
return betterAuthLoginUser{}, false
|
||||
}
|
||||
|
||||
payload.User.Email = strings.ToLower(strings.TrimSpace(firstNonEmpty(payload.User.Email, email)))
|
||||
payload.User.Name = strings.TrimSpace(payload.User.Name)
|
||||
if payload.User.Name == "" {
|
||||
payload.User.Name = "Containr User"
|
||||
}
|
||||
|
||||
if payload.User.Email == "" {
|
||||
return betterAuthLoginUser{}, false
|
||||
}
|
||||
|
||||
return payload.User, true
|
||||
}
|
||||
|
||||
func betterAuthSignInURL() (string, error) {
|
||||
rawTarget := strings.TrimSpace(os.Getenv("BETTER_AUTH_PROXY_URL"))
|
||||
if rawTarget == "" {
|
||||
rawTarget = "http://127.0.0.1:3001"
|
||||
}
|
||||
|
||||
target, err := parseAuthProxyTarget(rawTarget)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return target.ResolveReference(&url.URL{Path: "/api/auth/sign-in/email"}).String(), nil
|
||||
}
|
||||
|
||||
func betterAuthSignUpURL() (string, error) {
|
||||
rawTarget := strings.TrimSpace(os.Getenv("BETTER_AUTH_PROXY_URL"))
|
||||
if rawTarget == "" {
|
||||
rawTarget = "http://127.0.0.1:3001"
|
||||
}
|
||||
|
||||
target, err := parseAuthProxyTarget(rawTarget)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return target.ResolveReference(&url.URL{Path: "/api/auth/sign-up/email"}).String(), nil
|
||||
}
|
||||
|
||||
func createLocalUserFromBetterAuth(db *database.DB, authUser betterAuthLoginUser) (User, error) {
|
||||
email := strings.ToLower(strings.TrimSpace(authUser.Email))
|
||||
if email == "" {
|
||||
return User{}, errors.New("email is required")
|
||||
}
|
||||
|
||||
name := strings.TrimSpace(authUser.Name)
|
||||
if name == "" {
|
||||
name = "Containr User"
|
||||
}
|
||||
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(uuid.NewString()), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
|
||||
var user User
|
||||
err = db.QueryRow(`
|
||||
INSERT INTO users (email, password_hash, name, avatar_url)
|
||||
VALUES ($1, $2, $3, NULLIF($4, ''))
|
||||
ON CONFLICT (email) DO UPDATE
|
||||
SET name = EXCLUDED.name,
|
||||
avatar_url = COALESCE(EXCLUDED.avatar_url, users.avatar_url),
|
||||
updated_at = NOW()
|
||||
RETURNING id, email, name, COALESCE(avatar_url, ''), created_at
|
||||
`, email, string(hashedPassword), name, strings.TrimSpace(authUser.Image)).
|
||||
Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.CreatedAt)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func handleRegister(c *gin.Context) {
|
||||
var req RegisterRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -91,9 +264,19 @@ func handleRegister(c *gin.Context) {
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
jwtSecret := c.MustGet("jwt_secret").(string)
|
||||
|
||||
// Check if user already exists
|
||||
var count int
|
||||
err := db.QueryRow("SELECT COUNT(*) FROM users WHERE email = $1", req.Email).Scan(&count)
|
||||
err := db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
||||
return
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Public registration is disabled after bootstrap"})
|
||||
return
|
||||
}
|
||||
|
||||
err = db.QueryRow("SELECT COUNT(*) FROM users WHERE email = $1", strings.ToLower(strings.TrimSpace(req.Email))).Scan(&count)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
||||
return
|
||||
@@ -137,6 +320,121 @@ func handleRegister(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func handleCreateUser(c *gin.Context) {
|
||||
var req ManualUserCreateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
if req.Email == "" || req.Name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Email and name are required"})
|
||||
return
|
||||
}
|
||||
|
||||
var existing int
|
||||
if err := db.QueryRow("SELECT COUNT(*) FROM users WHERE email = $1", req.Email).Scan(&existing); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
||||
return
|
||||
}
|
||||
if existing > 0 {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "User already exists"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := createBetterAuthUser(c, req.Name, req.Email, req.Password); err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, errBetterAuthUserExists) {
|
||||
status = http.StatusConflict
|
||||
}
|
||||
c.JSON(status, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"})
|
||||
return
|
||||
}
|
||||
|
||||
var user User
|
||||
err = db.QueryRow(`
|
||||
INSERT INTO users (email, password_hash, name)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, email, name, COALESCE(avatar_url, ''), created_at
|
||||
`, req.Email, string(hashedPassword), req.Name).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.CreatedAt)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "User already exists"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"user": user})
|
||||
}
|
||||
|
||||
var errBetterAuthUserExists = errors.New("User already exists")
|
||||
|
||||
func createBetterAuthUser(c *gin.Context, name string, email string, password string) error {
|
||||
endpoint, err := betterAuthSignUpURL()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
body, err := json.Marshal(gin.H{
|
||||
"name": name,
|
||||
"email": email,
|
||||
"password": password,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
request, err := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Accept", "application/json")
|
||||
if token := strings.TrimSpace(os.Getenv("BETTER_AUTH_INTERNAL_TOKEN")); token != "" {
|
||||
request.Header.Set("X-Containr-Auth-Internal", token)
|
||||
}
|
||||
|
||||
response, err := (&http.Client{Timeout: 5 * time.Second}).Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode >= http.StatusOK && response.StatusCode < http.StatusMultipleChoices {
|
||||
return nil
|
||||
}
|
||||
|
||||
var payload map[string]interface{}
|
||||
_ = json.NewDecoder(response.Body).Decode(&payload)
|
||||
message := "Failed to create auth user"
|
||||
if rawMessage, ok := payload["message"].(string); ok && strings.TrimSpace(rawMessage) != "" {
|
||||
message = rawMessage
|
||||
} else if rawError, ok := payload["error"].(string); ok && strings.TrimSpace(rawError) != "" {
|
||||
message = rawError
|
||||
}
|
||||
|
||||
if response.StatusCode == http.StatusConflict || strings.Contains(strings.ToLower(message), "already") {
|
||||
return errBetterAuthUserExists
|
||||
}
|
||||
|
||||
return errors.New(message)
|
||||
}
|
||||
|
||||
func countLocalUsers(db *database.DB) (int, error) {
|
||||
var count int
|
||||
if err := db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func handleGetProfile(c *gin.Context) {
|
||||
userID := c.MustGet("user_id").(string)
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"containr/internal/database"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
@@ -12,7 +13,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func setupAuthProxyRoutes(router *gin.Engine, cfg *config.Config) {
|
||||
func setupAuthProxyRoutes(router *gin.Engine, cfg *config.Config, db *database.DB) {
|
||||
targetURL, err := parseAuthProxyTarget(cfg.BetterAuthProxyURL)
|
||||
if err != nil {
|
||||
log.Printf("Warning: auth proxy disabled: %v", err)
|
||||
@@ -28,6 +29,12 @@ func setupAuthProxyRoutes(router *gin.Engine, cfg *config.Config) {
|
||||
}
|
||||
|
||||
handler := func(c *gin.Context) {
|
||||
if shouldBlockPublicBetterAuthSignup(c, cfg, db) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Public registration is disabled after bootstrap"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
proxy.ServeHTTP(c.Writer, c.Request)
|
||||
c.Abort()
|
||||
}
|
||||
@@ -36,6 +43,33 @@ func setupAuthProxyRoutes(router *gin.Engine, cfg *config.Config) {
|
||||
router.Any("/api/auth/*proxyPath", handler)
|
||||
}
|
||||
|
||||
func shouldBlockPublicBetterAuthSignup(c *gin.Context, cfg *config.Config, db *database.DB) bool {
|
||||
if db == nil || db.DB == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if !strings.EqualFold(c.Request.Method, http.MethodPost) {
|
||||
return false
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(c.Request.URL.Path, "/sign-up/email") {
|
||||
return false
|
||||
}
|
||||
|
||||
internalToken := strings.TrimSpace(cfg.BetterAuthInternalToken)
|
||||
if internalToken != "" && c.GetHeader("X-Containr-Auth-Internal") == internalToken {
|
||||
return false
|
||||
}
|
||||
|
||||
count, err := countLocalUsers(db)
|
||||
if err != nil {
|
||||
log.Printf("Failed to inspect signup bootstrap state: %v", err)
|
||||
return true
|
||||
}
|
||||
|
||||
return count > 0
|
||||
}
|
||||
|
||||
func parseAuthProxyTarget(rawTarget string) (*url.URL, error) {
|
||||
trimmed := strings.TrimSpace(rawTarget)
|
||||
if trimmed == "" {
|
||||
|
||||
@@ -1306,3 +1306,208 @@ func generateWebhookSecret() (string, error) {
|
||||
}
|
||||
return hex.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
// handleDeleteGitProvider removes a provider connection. Repositories and
|
||||
// webhooks are removed by ON DELETE CASCADE.
|
||||
func handleDeleteGitProvider(c *gin.Context) {
|
||||
ctx, ok := requireGitRequestContext(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
providerID := strings.TrimSpace(c.Param("providerId"))
|
||||
if _, err := uuid.Parse(providerID); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid provider ID"})
|
||||
return
|
||||
}
|
||||
|
||||
result, err := ctx.db.Exec(
|
||||
"DELETE FROM git_providers WHERE id = $1 AND user_id = $2",
|
||||
providerID, ctx.userID,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete provider"})
|
||||
return
|
||||
}
|
||||
|
||||
affected, _ := result.RowsAffected()
|
||||
if affected == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Provider not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Provider disconnected"})
|
||||
}
|
||||
|
||||
// handleGetGitRepositoryBranches lists branches for a repository through the
|
||||
// provider's stored credentials.
|
||||
func handleGetGitRepositoryBranches(c *gin.Context) {
|
||||
ctx, ok := requireGitRequestContext(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
providerID := strings.TrimSpace(c.Param("providerId"))
|
||||
if _, err := uuid.Parse(providerID); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid provider ID"})
|
||||
return
|
||||
}
|
||||
|
||||
owner := strings.TrimSpace(c.Param("owner"))
|
||||
repo := strings.TrimSpace(c.Param("repo"))
|
||||
if owner == "" || repo == "" || strings.ContainsAny(owner+repo, " \t\r\n") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid repository reference"})
|
||||
return
|
||||
}
|
||||
|
||||
var providerName, providerAPIURL, providerAccessToken string
|
||||
err := ctx.db.QueryRow(`
|
||||
SELECT name, api_url, access_token
|
||||
FROM git_providers
|
||||
WHERE id = $1 AND user_id = $2
|
||||
`, providerID, ctx.userID).Scan(&providerName, &providerAPIURL, &providerAccessToken)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Provider not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"})
|
||||
return
|
||||
}
|
||||
|
||||
branches, err := fetchRepositoryBranches(providerName, providerAPIURL, providerAccessToken, owner, repo)
|
||||
if err != nil {
|
||||
log.Printf("branch fetch failed for %s/%s via %s: %v", owner, repo, providerName, err)
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "Failed to fetch branches from provider"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"branches": branches})
|
||||
}
|
||||
|
||||
type remoteBranch struct {
|
||||
Name string `json:"name"`
|
||||
Protected bool `json:"protected"`
|
||||
}
|
||||
|
||||
func fetchRepositoryBranches(providerName, providerAPIURL, accessToken, owner, repo string) ([]remoteBranch, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(providerName)) {
|
||||
case "github", "github_app":
|
||||
return fetchGitHubBranches(providerName, providerAPIURL, accessToken, owner, repo)
|
||||
case "gitea":
|
||||
return fetchGiteaBranches(providerAPIURL, accessToken, owner, repo)
|
||||
default:
|
||||
return nil, fmt.Errorf("branch listing is not supported for provider %s", providerName)
|
||||
}
|
||||
}
|
||||
|
||||
func fetchGitHubBranches(providerName, providerAPIURL, accessToken, owner, repo string) ([]remoteBranch, error) {
|
||||
baseURL := strings.TrimSuffix(strings.TrimSpace(providerAPIURL), "/")
|
||||
if baseURL == "" {
|
||||
baseURL = "https://api.github.com"
|
||||
}
|
||||
|
||||
token := strings.TrimSpace(accessToken)
|
||||
if strings.EqualFold(providerName, "github_app") {
|
||||
installationID, err := strconv.ParseInt(token, 10, 64)
|
||||
if err != nil || installationID <= 0 {
|
||||
return nil, fmt.Errorf("github_app installation id is invalid")
|
||||
}
|
||||
token, err = getGitHubAppInstallationToken(baseURL, installationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if token == "" {
|
||||
return nil, fmt.Errorf("github access token is missing")
|
||||
}
|
||||
|
||||
branches := make([]remoteBranch, 0)
|
||||
for page := 1; page <= 5; page++ {
|
||||
request, err := http.NewRequest(http.MethodGet,
|
||||
fmt.Sprintf("%s/repos/%s/%s/branches?per_page=100&page=%d", baseURL, owner, repo, page), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request.Header.Set("Accept", "application/vnd.github+json")
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
payload, _ := io.ReadAll(io.LimitReader(response.Body, 2048))
|
||||
response.Body.Close()
|
||||
return nil, fmt.Errorf("github branches request failed with status %d: %s", response.StatusCode, strings.TrimSpace(string(payload)))
|
||||
}
|
||||
|
||||
var rows []map[string]interface{}
|
||||
decodeErr := json.NewDecoder(response.Body).Decode(&rows)
|
||||
response.Body.Close()
|
||||
if decodeErr != nil {
|
||||
return nil, decodeErr
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
name := asGitString(row["name"])
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
branches = append(branches, remoteBranch{Name: name, Protected: asBoolValue(row["protected"])})
|
||||
}
|
||||
|
||||
if len(rows) < 100 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return branches, nil
|
||||
}
|
||||
|
||||
func fetchGiteaBranches(providerAPIURL, accessToken, owner, repo string) ([]remoteBranch, error) {
|
||||
baseURL := strings.TrimSuffix(strings.TrimSpace(providerAPIURL), "/")
|
||||
if baseURL == "" {
|
||||
baseURL = strings.TrimSuffix(strings.TrimSpace(getenvOrDefault("GITEA_BASE_URL", "")), "/")
|
||||
}
|
||||
if baseURL == "" {
|
||||
return nil, fmt.Errorf("gitea api_url is required")
|
||||
}
|
||||
|
||||
request, err := http.NewRequest(http.MethodGet,
|
||||
fmt.Sprintf("%s/api/v1/repos/%s/%s/branches?limit=100", baseURL, owner, repo), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request.Header.Set("Accept", "application/json")
|
||||
if token := strings.TrimSpace(accessToken); token != "" {
|
||||
request.Header.Set("Authorization", "token "+token)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
payload, _ := io.ReadAll(io.LimitReader(response.Body, 2048))
|
||||
return nil, fmt.Errorf("gitea branches request failed with status %d: %s", response.StatusCode, strings.TrimSpace(string(payload)))
|
||||
}
|
||||
|
||||
var rows []map[string]interface{}
|
||||
if err := json.NewDecoder(response.Body).Decode(&rows); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
branches := make([]remoteBranch, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
if name := asGitString(row["name"]); name != "" {
|
||||
branches = append(branches, remoteBranch{Name: name, Protected: asBoolValue(row["protected"])})
|
||||
}
|
||||
}
|
||||
return branches, nil
|
||||
}
|
||||
@@ -64,11 +64,15 @@ func handleGetLogs(c *gin.Context) {
|
||||
|
||||
dockerClient, exists := c.Get("docker_client")
|
||||
if !exists || dockerClient == nil {
|
||||
respondDependencyUnavailable(c, "docker", "Container log streaming is unavailable because Docker is not configured.")
|
||||
respondEmptyServiceLogs(c, "Container log streaming is unavailable because Docker is not configured.")
|
||||
return
|
||||
}
|
||||
|
||||
client := dockerClient.(*docker.Client)
|
||||
client, ok := dockerClient.(*docker.Client)
|
||||
if !ok || client == nil {
|
||||
respondEmptyServiceLogs(c, "Container log streaming is unavailable because Docker is not configured.")
|
||||
return
|
||||
}
|
||||
containerName := fmt.Sprintf("containr-%s", serviceID)
|
||||
|
||||
logOpts := docker.LogOptions{
|
||||
@@ -82,6 +86,10 @@ func handleGetLogs(c *gin.Context) {
|
||||
ctx := context.Background()
|
||||
logsReader, err := client.GetContainerLogs(ctx, containerName, logOpts)
|
||||
if err != nil {
|
||||
if isDockerNotFoundError(err) {
|
||||
respondEmptyServiceLogs(c, "No container logs are available for this service yet.")
|
||||
return
|
||||
}
|
||||
respondDependencyUnavailable(c, "docker", "Failed to fetch container logs. The container may be unavailable.")
|
||||
return
|
||||
}
|
||||
@@ -156,6 +164,13 @@ func handleGetLogs(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"logs": logEntries})
|
||||
}
|
||||
|
||||
func respondEmptyServiceLogs(c *gin.Context, message string) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"logs": []LogEntry{},
|
||||
"message": message,
|
||||
})
|
||||
}
|
||||
|
||||
func handleGetDeploymentLogs(c *gin.Context) {
|
||||
db, exists := c.Get("db")
|
||||
if !exists {
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"containr/internal/database"
|
||||
"containr/internal/docker"
|
||||
|
||||
containertypes "github.com/docker/docker/api/types/container"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ServiceMetricsResponse is the aggregated live telemetry for a service,
|
||||
// sourced from Docker container stats for containers named
|
||||
// containr-<serviceID>-<deploymentID>-<index>.
|
||||
type ServiceMetricsResponse struct {
|
||||
ServiceID string `json:"service_id"`
|
||||
Status string `json:"status"` // ok, no_containers, docker_unavailable
|
||||
Instances []ServiceInstanceMetrics `json:"instances"`
|
||||
CPUPercent float64 `json:"cpu_percent"`
|
||||
MemoryUsageBytes uint64 `json:"memory_usage_bytes"`
|
||||
MemoryLimitBytes uint64 `json:"memory_limit_bytes"`
|
||||
NetworkRxBytes uint64 `json:"network_rx_bytes"`
|
||||
NetworkTxBytes uint64 `json:"network_tx_bytes"`
|
||||
CollectedAt time.Time `json:"collected_at"`
|
||||
}
|
||||
|
||||
// ServiceInstanceMetrics holds live stats for one running container.
|
||||
type ServiceInstanceMetrics struct {
|
||||
ContainerID string `json:"container_id"`
|
||||
Name string `json:"name"`
|
||||
State string `json:"state"`
|
||||
CPUPercent float64 `json:"cpu_percent"`
|
||||
MemoryUsageBytes uint64 `json:"memory_usage_bytes"`
|
||||
MemoryLimitBytes uint64 `json:"memory_limit_bytes"`
|
||||
NetworkRxBytes uint64 `json:"network_rx_bytes"`
|
||||
NetworkTxBytes uint64 `json:"network_tx_bytes"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
}
|
||||
|
||||
// handleGetServiceMetrics returns real resource usage for the service's
|
||||
// Docker containers. Empty result when the service has never deployed.
|
||||
func handleGetServiceMetrics(c *gin.Context) {
|
||||
dbValue, exists := c.Get("db")
|
||||
if !exists {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"})
|
||||
return
|
||||
}
|
||||
db := dbValue.(*database.DB)
|
||||
|
||||
serviceID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID"})
|
||||
return
|
||||
}
|
||||
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
var ownerID string
|
||||
var status string
|
||||
err = db.QueryRow(
|
||||
`SELECT p.owner_id, COALESCE(s.status, '')
|
||||
FROM services s JOIN projects p ON s.project_id = p.id
|
||||
WHERE s.id = $1`,
|
||||
serviceID,
|
||||
).Scan(&ownerID, &status)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"})
|
||||
return
|
||||
}
|
||||
if ownerID != userID.(string) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"})
|
||||
return
|
||||
}
|
||||
|
||||
response := ServiceMetricsResponse{
|
||||
ServiceID: serviceID.String(),
|
||||
Status: "ok",
|
||||
Instances: []ServiceInstanceMetrics{},
|
||||
CollectedAt: time.Now(),
|
||||
}
|
||||
|
||||
dockerValue, exists := c.Get("docker_client")
|
||||
if !exists || dockerValue == nil {
|
||||
response.Status = "docker_unavailable"
|
||||
c.JSON(http.StatusOK, gin.H{"metrics": response})
|
||||
return
|
||||
}
|
||||
dockerClient := dockerValue.(*docker.Client)
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
containers, err := dockerClient.ListContainers(ctx, false)
|
||||
if err != nil {
|
||||
response.Status = "docker_unavailable"
|
||||
c.JSON(http.StatusOK, gin.H{"metrics": response})
|
||||
return
|
||||
}
|
||||
|
||||
namePrefix := fmt.Sprintf("containr-%s-", serviceID.String())
|
||||
matched := 0
|
||||
for _, ctr := range containers {
|
||||
name := ""
|
||||
for _, n := range ctr.Names {
|
||||
name = strings.TrimPrefix(n, "/")
|
||||
if strings.HasPrefix(name, namePrefix) {
|
||||
break
|
||||
}
|
||||
name = ""
|
||||
}
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
matched++
|
||||
|
||||
instance := ServiceInstanceMetrics{
|
||||
ContainerID: ctr.ID,
|
||||
Name: name,
|
||||
State: ctr.State,
|
||||
}
|
||||
|
||||
if info, err := dockerClient.GetContainer(ctx, ctr.ID); err == nil {
|
||||
if info.State != nil && info.State.StartedAt != "" {
|
||||
if parsed, perr := time.Parse(time.RFC3339Nano, info.State.StartedAt); perr == nil {
|
||||
instance.StartedAt = parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ctr.State == "running" {
|
||||
if stats, err := readContainerStats(ctx, dockerClient, ctr.ID); err == nil {
|
||||
instance.CPUPercent = stats.cpuPercent
|
||||
instance.MemoryUsageBytes = stats.memoryUsage
|
||||
instance.MemoryLimitBytes = stats.memoryLimit
|
||||
instance.NetworkRxBytes = stats.networkRx
|
||||
instance.NetworkTxBytes = stats.networkTx
|
||||
}
|
||||
}
|
||||
|
||||
response.Instances = append(response.Instances, instance)
|
||||
response.CPUPercent += instance.CPUPercent
|
||||
response.MemoryUsageBytes += instance.MemoryUsageBytes
|
||||
response.MemoryLimitBytes += instance.MemoryLimitBytes
|
||||
response.NetworkRxBytes += instance.NetworkRxBytes
|
||||
response.NetworkTxBytes += instance.NetworkTxBytes
|
||||
}
|
||||
|
||||
if matched == 0 {
|
||||
response.Status = "no_containers"
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"metrics": response})
|
||||
}
|
||||
|
||||
type containerStatsSnapshot struct {
|
||||
cpuPercent float64
|
||||
memoryUsage uint64
|
||||
memoryLimit uint64
|
||||
networkRx uint64
|
||||
networkTx uint64
|
||||
}
|
||||
|
||||
func readContainerStats(ctx context.Context, client *docker.Client, containerID string) (*containerStatsSnapshot, error) {
|
||||
reader, err := client.GetContainerStats(ctx, containerID, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer reader.Body.Close()
|
||||
|
||||
var stats containertypes.StatsResponse
|
||||
if err := json.NewDecoder(reader.Body).Decode(&stats); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
snapshot := &containerStatsSnapshot{
|
||||
memoryUsage: stats.MemoryStats.Usage,
|
||||
memoryLimit: stats.MemoryStats.Limit,
|
||||
}
|
||||
|
||||
cpuDelta := float64(stats.CPUStats.CPUUsage.TotalUsage) - float64(stats.PreCPUStats.CPUUsage.TotalUsage)
|
||||
systemDelta := float64(stats.CPUStats.SystemUsage) - float64(stats.PreCPUStats.SystemUsage)
|
||||
onlineCPUs := float64(stats.CPUStats.OnlineCPUs)
|
||||
if onlineCPUs == 0 {
|
||||
onlineCPUs = float64(len(stats.CPUStats.CPUUsage.PercpuUsage))
|
||||
}
|
||||
if cpuDelta > 0 && systemDelta > 0 && onlineCPUs > 0 {
|
||||
snapshot.cpuPercent = (cpuDelta / systemDelta) * onlineCPUs * 100
|
||||
}
|
||||
|
||||
for _, net := range stats.Networks {
|
||||
snapshot.networkRx += net.RxBytes
|
||||
snapshot.networkTx += net.TxBytes
|
||||
}
|
||||
|
||||
return snapshot, nil
|
||||
}
|
||||
@@ -23,7 +23,7 @@ import (
|
||||
|
||||
func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg *config.Config) {
|
||||
// Expose Better Auth through backend so frontend can use a single backend origin.
|
||||
setupAuthProxyRoutes(router, cfg)
|
||||
setupAuthProxyRoutes(router, cfg, db)
|
||||
|
||||
// Initialize Docker client (non-fatal if it fails)
|
||||
var dockerClient *docker.Client
|
||||
@@ -158,11 +158,15 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
router.HEAD("/ready", healthHandler)
|
||||
|
||||
// API v1 routes
|
||||
publicAgents := router.Group("/api")
|
||||
agentHandler.SetupPublicRoutes(publicAgents)
|
||||
|
||||
v1 := router.Group("/api/v1")
|
||||
{
|
||||
// Public routes (no authentication required)
|
||||
public := v1.Group("/")
|
||||
{
|
||||
public.GET("/auth/bootstrap", handleAuthBootstrap)
|
||||
public.POST("/auth/login", handleLogin)
|
||||
public.POST("/auth/register", handleRegister)
|
||||
}
|
||||
@@ -174,6 +178,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
// User routes
|
||||
protected.GET("/user/profile", handleGetProfile)
|
||||
protected.PUT("/user/profile", handleUpdateProfile)
|
||||
protected.POST("/users", handleCreateUser)
|
||||
|
||||
// Project routes
|
||||
protected.GET("/projects", handleGetProjects)
|
||||
@@ -192,6 +197,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
protected.GET("/services/:id", handleGetService)
|
||||
protected.PUT("/services/:id", handleUpdateService)
|
||||
protected.DELETE("/services/:id", handleDeleteService)
|
||||
protected.GET("/services/:id/metrics", handleGetServiceMetrics)
|
||||
|
||||
// Deployment routes
|
||||
protected.GET("/services/:id/deployments", handleGetDeployments)
|
||||
@@ -212,7 +218,9 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
protected.POST("/git/github-app/connect", handleConnectGitHubApp)
|
||||
protected.GET("/git/providers", handleGetGitProviders)
|
||||
protected.POST("/git/providers", handleCreateGitProvider)
|
||||
protected.DELETE("/git/providers/:providerId", handleDeleteGitProvider)
|
||||
protected.GET("/git/providers/:providerId/repositories", handleGetGitRepositories)
|
||||
protected.GET("/git/providers/:providerId/repositories/:owner/:repo/branches", handleGetGitRepositoryBranches)
|
||||
protected.POST("/git/repositories/connect", handleConnectGitRepository)
|
||||
protected.GET("/git/repositories", handleGetConnectedRepositories)
|
||||
protected.POST("/git/webhooks", handleCreateWebhook)
|
||||
@@ -226,6 +234,11 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
protected.POST("/builds/plan", buildHandler.GetBuildPlan)
|
||||
protected.GET("/builds/detect", buildHandler.DetectBuildType)
|
||||
|
||||
// System routes
|
||||
protected.GET("/system/upgrade/status", handleGetUpgradeStatus)
|
||||
protected.POST("/system/upgrade/pull", handlePullUpgradeImage)
|
||||
protected.GET("/system/host", handleGetHostMonitoring)
|
||||
|
||||
// Scaling routes
|
||||
scalingHandler.RegisterRoutes(protected)
|
||||
haAPIManager.RegisterRoutes(protected)
|
||||
@@ -241,9 +254,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
protected.POST("/databases/:id/restore", databaseHandler.RestoreBackup)
|
||||
|
||||
// Node Agent routes
|
||||
api := router.Group("/api")
|
||||
api.Use(middleware.Auth(cfg.JWTSecret))
|
||||
agentHandler.SetupRoutes(api)
|
||||
agentHandler.SetupRoutes(protected)
|
||||
|
||||
// Preview Environments routes
|
||||
protected.GET("/projects/:id/preview-environments", handleGetPreviewEnvironments)
|
||||
|
||||
@@ -99,8 +99,18 @@ func handleGetServices(c *gin.Context) {
|
||||
|
||||
// Get services for the project
|
||||
rows, err := db.(*database.DB).Query(
|
||||
`SELECT id, project_id, name, type, status, image, command, environment,
|
||||
git_repo, git_branch, build_path, cpu, memory, created_at, updated_at
|
||||
`SELECT id, project_id, name,
|
||||
COALESCE(type, service_type, ''),
|
||||
COALESCE(status, ''),
|
||||
COALESCE(image, image_name, ''),
|
||||
COALESCE(command, start_command, ''),
|
||||
COALESCE(environment, ''),
|
||||
COALESCE(git_repo, source_url, ''),
|
||||
COALESCE(git_branch, ''),
|
||||
COALESCE(build_path, ''),
|
||||
COALESCE(cpu, ''),
|
||||
COALESCE(memory, ''),
|
||||
created_at, updated_at
|
||||
FROM services
|
||||
WHERE project_id = $1
|
||||
ORDER BY created_at DESC`,
|
||||
@@ -228,15 +238,25 @@ func handleCreateService(c *gin.Context) {
|
||||
service.Memory = "512Mi"
|
||||
}
|
||||
|
||||
environmentID, err := getProjectEnvironmentID(db.(*database.DB), service.ProjectID, service.Environment)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to resolve service environment"})
|
||||
return
|
||||
}
|
||||
|
||||
sourceType := inferServiceSourceType(service)
|
||||
|
||||
// Insert service into database
|
||||
_, err = db.(*database.DB).Exec(
|
||||
`INSERT INTO services
|
||||
(id, project_id, name, type, status, image, command, environment,
|
||||
(id, project_id, name, environment_id, service_type, source_type, source_url, image_name,
|
||||
build_command, start_command, type, status, image, command, environment,
|
||||
git_repo, git_branch, build_path, cpu, memory, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)`,
|
||||
service.ID, service.ProjectID, service.Name, service.Type, service.Status,
|
||||
service.Image, service.Command, service.Environment, service.GitRepo,
|
||||
service.GitBranch, service.BuildPath, service.CPU, service.Memory,
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22)`,
|
||||
service.ID, service.ProjectID, service.Name, environmentID, service.Type,
|
||||
sourceType, firstNonEmpty(service.GitRepo, service.Image), service.Image,
|
||||
"", service.Command, service.Type, service.Status, service.Image, service.Command,
|
||||
service.Environment, service.GitRepo, service.GitBranch, service.BuildPath, service.CPU, service.Memory,
|
||||
service.CreatedAt, service.UpdatedAt,
|
||||
)
|
||||
|
||||
@@ -248,6 +268,26 @@ func handleCreateService(c *gin.Context) {
|
||||
c.JSON(http.StatusCreated, gin.H{"service": service})
|
||||
}
|
||||
|
||||
func getProjectEnvironmentID(db *database.DB, projectID uuid.UUID, environment string) (uuid.UUID, error) {
|
||||
var environmentID uuid.UUID
|
||||
err := db.QueryRow(
|
||||
"SELECT id FROM environments WHERE project_id = $1 AND name = $2",
|
||||
projectID,
|
||||
environment,
|
||||
).Scan(&environmentID)
|
||||
return environmentID, err
|
||||
}
|
||||
|
||||
func inferServiceSourceType(service Service) string {
|
||||
if service.GitRepo != "" {
|
||||
return "github"
|
||||
}
|
||||
if service.Image != "" {
|
||||
return "image"
|
||||
}
|
||||
return "dockerfile"
|
||||
}
|
||||
|
||||
// handleGetService retrieves a specific service
|
||||
func handleGetService(c *gin.Context) {
|
||||
db, exists := c.Get("db")
|
||||
@@ -273,8 +313,17 @@ func handleGetService(c *gin.Context) {
|
||||
// Get service with project ownership check
|
||||
var service Service
|
||||
err = db.(*database.DB).QueryRow(
|
||||
`SELECT s.id, s.project_id, s.name, s.type, s.status, s.image, s.command,
|
||||
s.environment, s.git_repo, s.git_branch, s.build_path, s.cpu, s.memory,
|
||||
`SELECT s.id, s.project_id, s.name,
|
||||
COALESCE(s.type, s.service_type, ''),
|
||||
COALESCE(s.status, ''),
|
||||
COALESCE(s.image, s.image_name, ''),
|
||||
COALESCE(s.command, s.start_command, ''),
|
||||
COALESCE(s.environment, ''),
|
||||
COALESCE(s.git_repo, s.source_url, ''),
|
||||
COALESCE(s.git_branch, ''),
|
||||
COALESCE(s.build_path, ''),
|
||||
COALESCE(s.cpu, ''),
|
||||
COALESCE(s.memory, ''),
|
||||
s.created_at, s.updated_at
|
||||
FROM services s
|
||||
JOIN projects p ON s.project_id = p.id
|
||||
@@ -326,8 +375,17 @@ func handleUpdateService(c *gin.Context) {
|
||||
// Check if service exists and user has access
|
||||
var existingService Service
|
||||
err = db.(*database.DB).QueryRow(
|
||||
`SELECT s.id, s.project_id, s.name, s.type, s.status, s.image, s.command,
|
||||
s.environment, s.git_repo, s.git_branch, s.build_path, s.cpu, s.memory,
|
||||
`SELECT s.id, s.project_id, s.name,
|
||||
COALESCE(s.type, s.service_type, ''),
|
||||
COALESCE(s.status, ''),
|
||||
COALESCE(s.image, s.image_name, ''),
|
||||
COALESCE(s.command, s.start_command, ''),
|
||||
COALESCE(s.environment, ''),
|
||||
COALESCE(s.git_repo, s.source_url, ''),
|
||||
COALESCE(s.git_branch, ''),
|
||||
COALESCE(s.build_path, ''),
|
||||
COALESCE(s.cpu, ''),
|
||||
COALESCE(s.memory, ''),
|
||||
s.created_at, s.updated_at
|
||||
FROM services s
|
||||
JOIN projects p ON s.project_id = p.id
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"containr/internal/docker"
|
||||
|
||||
"github.com/docker/docker/api/types/registry"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type upgradeStatusResponse struct {
|
||||
ImageRef string `json:"image_ref"`
|
||||
Registry string `json:"registry"`
|
||||
DockerAvailable bool `json:"docker_available"`
|
||||
Installed bool `json:"installed"`
|
||||
Digest string `json:"digest,omitempty"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
AuthConfigured bool `json:"auth_configured"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type hostMonitoringResponse struct {
|
||||
Hostname string `json:"hostname"`
|
||||
OS string `json:"os"`
|
||||
Architecture string `json:"architecture"`
|
||||
CPU hostCPU `json:"cpu"`
|
||||
Memory hostMemory `json:"memory"`
|
||||
Storage hostStorage `json:"storage"`
|
||||
Load SystemLoad `json:"load"`
|
||||
UptimeSeconds int64 `json:"uptime_seconds"`
|
||||
DockerAvailable bool `json:"docker_available"`
|
||||
Docker map[string]any `json:"docker,omitempty"`
|
||||
CollectedAt string `json:"collected_at"`
|
||||
}
|
||||
|
||||
type hostCPU struct {
|
||||
Cores int `json:"cores"`
|
||||
}
|
||||
|
||||
type hostMemory struct {
|
||||
Total uint64 `json:"total"`
|
||||
Used uint64 `json:"used"`
|
||||
Available uint64 `json:"available"`
|
||||
UsagePct float64 `json:"usage_percent"`
|
||||
}
|
||||
|
||||
type hostStorage struct {
|
||||
Path string `json:"path"`
|
||||
Total uint64 `json:"total"`
|
||||
Used uint64 `json:"used"`
|
||||
Available uint64 `json:"available"`
|
||||
UsagePct float64 `json:"usage_percent"`
|
||||
}
|
||||
|
||||
func handleGetUpgradeStatus(c *gin.Context) {
|
||||
imageRef := configuredUpgradeImageRef()
|
||||
dockerClient, _ := c.Get("docker_client")
|
||||
client, _ := dockerClient.(*docker.Client)
|
||||
|
||||
response := upgradeStatusResponse{
|
||||
ImageRef: imageRef,
|
||||
Registry: registryHost(imageRef),
|
||||
DockerAvailable: client != nil,
|
||||
AuthConfigured: upgradeRegistryAuthConfigured(),
|
||||
Message: "Upgrade image auto-detected",
|
||||
}
|
||||
|
||||
if imageRef == "" {
|
||||
response.Message = "Set CONTAINR_UPGRADE_IMAGE_REF or CONTAINR_IMAGE_REF to enable image pulls"
|
||||
c.JSON(http.StatusOK, response)
|
||||
return
|
||||
}
|
||||
|
||||
if client == nil {
|
||||
response.Message = "Docker client unavailable"
|
||||
c.JSON(http.StatusOK, response)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
info, err := client.GetImageInfo(ctx, imageRef)
|
||||
if err != nil {
|
||||
response.Message = "Image not installed locally"
|
||||
c.JSON(http.StatusOK, response)
|
||||
return
|
||||
}
|
||||
|
||||
response.Installed = true
|
||||
response.Digest = info.Digest
|
||||
response.Size = info.Size
|
||||
response.Message = "Image installed locally"
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
func handlePullUpgradeImage(c *gin.Context) {
|
||||
imageRef := configuredUpgradeImageRef()
|
||||
if imageRef == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Set CONTAINR_UPGRADE_IMAGE_REF or CONTAINR_IMAGE_REF before pulling"})
|
||||
return
|
||||
}
|
||||
|
||||
dockerClient, _ := c.Get("docker_client")
|
||||
client, ok := dockerClient.(*docker.Client)
|
||||
if !ok || client == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Docker client unavailable"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
reader, err := client.PullImage(ctx, imageRef, upgradeRegistryAuth())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer reader.Close()
|
||||
_, _ = io.Copy(io.Discard, reader)
|
||||
|
||||
info, infoErr := client.GetImageInfo(ctx, imageRef)
|
||||
response := gin.H{
|
||||
"image_ref": imageRef,
|
||||
"registry": registryHost(imageRef),
|
||||
"message": "Image pull completed",
|
||||
}
|
||||
if infoErr == nil {
|
||||
response["digest"] = info.Digest
|
||||
response["size"] = info.Size
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
func handleGetHostMonitoring(c *gin.Context) {
|
||||
hostname, _ := os.Hostname()
|
||||
memory := readHostMemory()
|
||||
storage := readHostStorage("/")
|
||||
load := readSystemLoad()
|
||||
uptime := readSystemUptime()
|
||||
|
||||
response := hostMonitoringResponse{
|
||||
Hostname: hostname,
|
||||
OS: runtime.GOOS,
|
||||
Architecture: runtime.GOARCH,
|
||||
CPU: hostCPU{Cores: runtime.NumCPU()},
|
||||
Memory: memory,
|
||||
Storage: storage,
|
||||
Load: load,
|
||||
UptimeSeconds: uptime,
|
||||
CollectedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
if dockerClient, ok := c.Get("docker_client"); ok {
|
||||
if client, ok := dockerClient.(*docker.Client); ok && client != nil {
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
|
||||
defer cancel()
|
||||
if info, err := client.GetSystemInfo(ctx); err == nil {
|
||||
response.DockerAvailable = true
|
||||
response.Docker = map[string]any{
|
||||
"containers": info.Containers,
|
||||
"images": info.Images,
|
||||
"driver": info.Driver,
|
||||
"server": info.ServerVersion,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
func configuredUpgradeImageRef() string {
|
||||
for _, key := range []string{"CONTAINR_UPGRADE_IMAGE_REF", "CONTAINR_IMAGE_REF", "DOCKER_IMAGE_REF"} {
|
||||
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func registryHost(imageRef string) string {
|
||||
parts := strings.Split(imageRef, "/")
|
||||
if len(parts) > 1 && (strings.Contains(parts[0], ".") || strings.Contains(parts[0], ":")) {
|
||||
return parts[0]
|
||||
}
|
||||
if strings.HasPrefix(imageRef, "ghcr.io/") {
|
||||
return "ghcr.io"
|
||||
}
|
||||
return "docker.io"
|
||||
}
|
||||
|
||||
func upgradeRegistryAuthConfigured() bool {
|
||||
auth := upgradeRegistryAuth()
|
||||
return strings.TrimSpace(auth.Username) != "" || strings.TrimSpace(auth.Password) != "" || strings.TrimSpace(auth.Auth) != ""
|
||||
}
|
||||
|
||||
func upgradeRegistryAuth() registry.AuthConfig {
|
||||
username := firstConfigured("GITHUB_CONTAINER_REGISTRY_USER", "GHCR_USERNAME", "DOCKER_REGISTRY_USERNAME")
|
||||
password := firstConfigured("GITHUB_CONTAINER_REGISTRY_TOKEN", "GHCR_TOKEN", "DOCKER_REGISTRY_PASSWORD")
|
||||
return registry.AuthConfig{
|
||||
Username: username,
|
||||
Password: password,
|
||||
}
|
||||
}
|
||||
|
||||
func firstConfigured(keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func readHostMemory() hostMemory {
|
||||
values := map[string]uint64{}
|
||||
file, err := os.Open("/proc/meminfo")
|
||||
if err != nil {
|
||||
return hostMemory{}
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
fields := strings.Fields(scanner.Text())
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSuffix(fields[0], ":")
|
||||
value, parseErr := strconv.ParseUint(fields[1], 10, 64)
|
||||
if parseErr != nil {
|
||||
continue
|
||||
}
|
||||
values[key] = value * 1024
|
||||
}
|
||||
|
||||
total := values["MemTotal"]
|
||||
available := values["MemAvailable"]
|
||||
used := uint64(0)
|
||||
if total > available {
|
||||
used = total - available
|
||||
}
|
||||
|
||||
usage := 0.0
|
||||
if total > 0 {
|
||||
usage = float64(used) / float64(total) * 100
|
||||
}
|
||||
|
||||
return hostMemory{
|
||||
Total: total,
|
||||
Used: used,
|
||||
Available: available,
|
||||
UsagePct: usage,
|
||||
}
|
||||
}
|
||||
|
||||
func readHostStorage(path string) hostStorage {
|
||||
var stat syscall.Statfs_t
|
||||
if err := syscall.Statfs(path, &stat); err != nil {
|
||||
return hostStorage{Path: path}
|
||||
}
|
||||
|
||||
total := stat.Blocks * uint64(stat.Bsize)
|
||||
available := stat.Bavail * uint64(stat.Bsize)
|
||||
used := total - available
|
||||
usage := 0.0
|
||||
if total > 0 {
|
||||
usage = float64(used) / float64(total) * 100
|
||||
}
|
||||
|
||||
return hostStorage{
|
||||
Path: path,
|
||||
Total: total,
|
||||
Used: used,
|
||||
Available: available,
|
||||
UsagePct: usage,
|
||||
}
|
||||
}
|
||||
|
||||
func readSystemLoad() SystemLoad {
|
||||
data, err := os.ReadFile("/proc/loadavg")
|
||||
if err != nil {
|
||||
return SystemLoad{}
|
||||
}
|
||||
fields := strings.Fields(string(data))
|
||||
if len(fields) < 3 {
|
||||
return SystemLoad{}
|
||||
}
|
||||
load1, _ := strconv.ParseFloat(fields[0], 64)
|
||||
load5, _ := strconv.ParseFloat(fields[1], 64)
|
||||
load15, _ := strconv.ParseFloat(fields[2], 64)
|
||||
return SystemLoad{Load1M: load1, Load5M: load5, Load15M: load15}
|
||||
}
|
||||
|
||||
func readSystemUptime() int64 {
|
||||
data, err := os.ReadFile("/proc/uptime")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
fields := strings.Fields(string(data))
|
||||
if len(fields) == 0 {
|
||||
return 0
|
||||
}
|
||||
uptime, err := strconv.ParseFloat(fields[0], 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return int64(uptime)
|
||||
}
|
||||
@@ -279,9 +279,16 @@ func handleCreateFromTemplate(c *gin.Context) {
|
||||
|
||||
serviceImage := resolveTemplateRuntimeImage(config.Runtime)
|
||||
serviceCommand := strings.TrimSpace(config.StartCommand)
|
||||
buildCommand := strings.TrimSpace(config.BuildCommand)
|
||||
cpu, memory := defaultTemplateResources(serviceType)
|
||||
serviceEnvironment := "production"
|
||||
|
||||
environmentID, err := getProjectEnvironmentID(db, projectID, serviceEnvironment)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to resolve service environment"})
|
||||
return
|
||||
}
|
||||
|
||||
serviceID := uuid.New()
|
||||
now := time.Now()
|
||||
|
||||
@@ -294,18 +301,24 @@ func handleCreateFromTemplate(c *gin.Context) {
|
||||
|
||||
txQueries := queries.WithTx(tx)
|
||||
err = txQueries.CreateServiceFromTemplate(ctx, sqlcdb.CreateServiceFromTemplateParams{
|
||||
ID: serviceID,
|
||||
ProjectID: projectID,
|
||||
Name: req.Name,
|
||||
Type: serviceType,
|
||||
Status: "stopped",
|
||||
Image: sql.NullString{String: serviceImage, Valid: true},
|
||||
Command: sql.NullString{String: serviceCommand, Valid: true},
|
||||
Environment: sql.NullString{String: serviceEnvironment, Valid: true},
|
||||
Cpu: sql.NullString{String: cpu, Valid: true},
|
||||
Memory: sql.NullString{String: memory, Valid: true},
|
||||
CreatedAt: sql.NullTime{Time: now, Valid: true},
|
||||
UpdatedAt: sql.NullTime{Time: now, Valid: true},
|
||||
ID: serviceID,
|
||||
ProjectID: projectID,
|
||||
Name: req.Name,
|
||||
EnvironmentID: environmentID,
|
||||
ServiceType: serviceType,
|
||||
SourceType: "template",
|
||||
ImageName: sql.NullString{String: serviceImage, Valid: serviceImage != ""},
|
||||
BuildCommand: sql.NullString{String: buildCommand, Valid: buildCommand != ""},
|
||||
StartCommand: sql.NullString{String: serviceCommand, Valid: serviceCommand != ""},
|
||||
Type: serviceType,
|
||||
Status: "stopped",
|
||||
Image: sql.NullString{String: serviceImage, Valid: serviceImage != ""},
|
||||
Command: sql.NullString{String: serviceCommand, Valid: serviceCommand != ""},
|
||||
Environment: sql.NullString{String: serviceEnvironment, Valid: true},
|
||||
Cpu: sql.NullString{String: cpu, Valid: true},
|
||||
Memory: sql.NullString{String: memory, Valid: true},
|
||||
CreatedAt: sql.NullTime{Time: now, Valid: true},
|
||||
UpdatedAt: sql.NullTime{Time: now, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create service from template"})
|
||||
|
||||
@@ -146,6 +146,9 @@ func Load() *Config {
|
||||
cfg.CORSMethods = getenvSlice("CORS_METHODS", []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"})
|
||||
cfg.CORSHeaders = getenvSlice("CORS_HEADERS", []string{"Origin", "Content-Type", "Accept", "Authorization"})
|
||||
cfg.CORSCredentials = getenvBool("CORS_CREDENTIALS", true)
|
||||
if cfg.IsDevelopment() {
|
||||
cfg.CORSOrigins = expandDevelopmentCORSOrigins(cfg.CORSOrigins)
|
||||
}
|
||||
|
||||
cfg.APIKeyHeader = getenv("API_KEY_HEADER", "X-API-Key")
|
||||
cfg.ServiceTokenHeader = getenv("SERVICE_TOKEN_HEADER", "X-Service-Token")
|
||||
@@ -331,6 +334,57 @@ func getenvSliceWithAliases(keys []string, defaultValue []string) []string {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func expandDevelopmentCORSOrigins(origins []string) []string {
|
||||
hasLocalOrigin := false
|
||||
seen := make(map[string]struct{}, len(origins))
|
||||
expanded := make([]string, 0, len(origins)+12)
|
||||
|
||||
for _, origin := range origins {
|
||||
trimmed := strings.TrimSpace(origin)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[trimmed]; ok {
|
||||
continue
|
||||
}
|
||||
seen[trimmed] = struct{}{}
|
||||
expanded = append(expanded, trimmed)
|
||||
|
||||
if strings.HasPrefix(trimmed, "http://localhost") ||
|
||||
strings.HasPrefix(trimmed, "http://127.0.0.1") ||
|
||||
strings.HasPrefix(trimmed, "http://0.0.0.0") {
|
||||
hasLocalOrigin = true
|
||||
}
|
||||
}
|
||||
|
||||
if !hasLocalOrigin {
|
||||
return expanded
|
||||
}
|
||||
|
||||
for _, origin := range []string{
|
||||
"http://localhost:3000",
|
||||
"http://localhost:5173",
|
||||
"http://localhost:5174",
|
||||
"http://localhost:5175",
|
||||
"http://127.0.0.1:3000",
|
||||
"http://127.0.0.1:5173",
|
||||
"http://127.0.0.1:5174",
|
||||
"http://127.0.0.1:5175",
|
||||
"http://0.0.0.0:3000",
|
||||
"http://0.0.0.0:5173",
|
||||
"http://0.0.0.0:5174",
|
||||
"http://0.0.0.0:5175",
|
||||
} {
|
||||
if _, ok := seen[origin]; ok {
|
||||
continue
|
||||
}
|
||||
seen[origin] = struct{}{}
|
||||
expanded = append(expanded, origin)
|
||||
}
|
||||
|
||||
return expanded
|
||||
}
|
||||
|
||||
// encodeDatabasePassword URL-encodes the password in a database connection string
|
||||
func encodeDatabasePassword(databaseURL string) string {
|
||||
// Find the password part between :@ and encode it
|
||||
|
||||
@@ -12,9 +12,14 @@ func TestLoadUsesCORSAllowedOriginsAlias(t *testing.T) {
|
||||
t.Setenv("CORS_ALLOWED_ORIGINS", "http://localhost:3000,https://app.example.com")
|
||||
|
||||
cfg := Load()
|
||||
want := []string{"http://localhost:3000", "https://app.example.com"}
|
||||
if !reflect.DeepEqual(cfg.CORSOrigins, want) {
|
||||
t.Fatalf("unexpected CORS origins: got %v want %v", cfg.CORSOrigins, want)
|
||||
if !containsAll(cfg.CORSOrigins, []string{
|
||||
"http://localhost:3000",
|
||||
"http://localhost:5173",
|
||||
"http://localhost:5174",
|
||||
"http://127.0.0.1:5174",
|
||||
"https://app.example.com",
|
||||
}) {
|
||||
t.Fatalf("unexpected CORS origins: got %v", cfg.CORSOrigins)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,3 +185,27 @@ func TestLoadUsesNewStartupDefaults(t *testing.T) {
|
||||
t.Fatalf("expected positive MAX_REQUEST_BODY_BYTES, got %d", cfg.MaxRequestBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDoesNotExpandNonLocalDevelopmentCORSOrigins(t *testing.T) {
|
||||
t.Setenv("ENVIRONMENT", "development")
|
||||
t.Setenv("CORS_ORIGINS", "https://preferred.example.com")
|
||||
|
||||
cfg := Load()
|
||||
want := []string{"https://preferred.example.com"}
|
||||
if !reflect.DeepEqual(cfg.CORSOrigins, want) {
|
||||
t.Fatalf("unexpected CORS origins: got %v want %v", cfg.CORSOrigins, want)
|
||||
}
|
||||
}
|
||||
|
||||
func containsAll(haystack []string, needles []string) bool {
|
||||
seen := make(map[string]struct{}, len(haystack))
|
||||
for _, item := range haystack {
|
||||
seen[item] = struct{}{}
|
||||
}
|
||||
for _, needle := range needles {
|
||||
if _, ok := seen[needle]; !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -31,23 +31,70 @@ func (q *Queries) CountServicesByProjectAndName(ctx context.Context, arg CountSe
|
||||
}
|
||||
|
||||
const createServiceFromTemplate = `-- name: CreateServiceFromTemplate :exec
|
||||
INSERT INTO services (id, project_id, name, type, status, image, command, environment, cpu, memory, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
INSERT INTO services (
|
||||
id,
|
||||
project_id,
|
||||
name,
|
||||
environment_id,
|
||||
service_type,
|
||||
source_type,
|
||||
source_url,
|
||||
image_name,
|
||||
build_command,
|
||||
start_command,
|
||||
type,
|
||||
status,
|
||||
image,
|
||||
command,
|
||||
environment,
|
||||
cpu,
|
||||
memory,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
$5,
|
||||
$6,
|
||||
$7,
|
||||
$8,
|
||||
$9,
|
||||
$10,
|
||||
$11,
|
||||
$12,
|
||||
$13,
|
||||
$14,
|
||||
$15,
|
||||
$16,
|
||||
$17,
|
||||
$18,
|
||||
$19
|
||||
)
|
||||
`
|
||||
|
||||
type CreateServiceFromTemplateParams struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
Image sql.NullString `json:"image"`
|
||||
Command sql.NullString `json:"command"`
|
||||
Environment sql.NullString `json:"environment"`
|
||||
Cpu sql.NullString `json:"cpu"`
|
||||
Memory sql.NullString `json:"memory"`
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
Name string `json:"name"`
|
||||
EnvironmentID uuid.UUID `json:"environment_id"`
|
||||
ServiceType string `json:"service_type"`
|
||||
SourceType string `json:"source_type"`
|
||||
SourceUrl sql.NullString `json:"source_url"`
|
||||
ImageName sql.NullString `json:"image_name"`
|
||||
BuildCommand sql.NullString `json:"build_command"`
|
||||
StartCommand sql.NullString `json:"start_command"`
|
||||
Type string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
Image sql.NullString `json:"image"`
|
||||
Command sql.NullString `json:"command"`
|
||||
Environment sql.NullString `json:"environment"`
|
||||
Cpu sql.NullString `json:"cpu"`
|
||||
Memory sql.NullString `json:"memory"`
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateServiceFromTemplate(ctx context.Context, arg CreateServiceFromTemplateParams) error {
|
||||
@@ -55,6 +102,13 @@ func (q *Queries) CreateServiceFromTemplate(ctx context.Context, arg CreateServi
|
||||
arg.ID,
|
||||
arg.ProjectID,
|
||||
arg.Name,
|
||||
arg.EnvironmentID,
|
||||
arg.ServiceType,
|
||||
arg.SourceType,
|
||||
arg.SourceUrl,
|
||||
arg.ImageName,
|
||||
arg.BuildCommand,
|
||||
arg.StartCommand,
|
||||
arg.Type,
|
||||
arg.Status,
|
||||
arg.Image,
|
||||
|
||||
@@ -25,8 +25,48 @@ FROM services
|
||||
WHERE project_id = $1 AND name = $2;
|
||||
|
||||
-- name: CreateServiceFromTemplate :exec
|
||||
INSERT INTO services (id, project_id, name, type, status, image, command, environment, cpu, memory, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12);
|
||||
INSERT INTO services (
|
||||
id,
|
||||
project_id,
|
||||
name,
|
||||
environment_id,
|
||||
service_type,
|
||||
source_type,
|
||||
source_url,
|
||||
image_name,
|
||||
build_command,
|
||||
start_command,
|
||||
type,
|
||||
status,
|
||||
image,
|
||||
command,
|
||||
environment,
|
||||
cpu,
|
||||
memory,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES (
|
||||
$1,
|
||||
$2,
|
||||
$3,
|
||||
$4,
|
||||
$5,
|
||||
$6,
|
||||
$7,
|
||||
$8,
|
||||
$9,
|
||||
$10,
|
||||
$11,
|
||||
$12,
|
||||
$13,
|
||||
$14,
|
||||
$15,
|
||||
$16,
|
||||
$17,
|
||||
$18,
|
||||
$19
|
||||
);
|
||||
|
||||
-- name: UpsertEnvironmentVariable :exec
|
||||
INSERT INTO environment_variables (id, service_id, key, value, is_secret, created_at, updated_at)
|
||||
|
||||
Generated
+15
-67
@@ -526,7 +526,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -543,7 +542,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -560,7 +558,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -577,7 +574,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -594,7 +590,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -611,7 +606,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -628,7 +622,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -645,7 +638,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -662,7 +654,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -679,7 +670,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -696,7 +686,6 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -713,7 +702,6 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -730,7 +718,6 @@
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -747,7 +734,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -764,7 +750,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -781,7 +766,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -798,7 +782,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -815,7 +798,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -832,7 +814,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -849,7 +830,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -866,7 +846,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -883,7 +862,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -900,7 +878,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -917,7 +894,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -934,7 +910,6 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -951,7 +926,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1241,7 +1215,7 @@
|
||||
"version": "0.3.11",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz",
|
||||
"integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
@@ -3108,7 +3082,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3122,7 +3095,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3136,7 +3108,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3150,7 +3121,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3164,7 +3134,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3178,7 +3147,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3192,7 +3160,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3206,7 +3173,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3220,7 +3186,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3234,7 +3199,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3248,7 +3212,6 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3262,7 +3225,6 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3276,7 +3238,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3290,7 +3251,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3304,7 +3264,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3318,7 +3277,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3332,7 +3290,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3346,7 +3303,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3360,7 +3316,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3374,7 +3329,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3388,7 +3342,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3402,7 +3355,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3416,7 +3368,6 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3430,7 +3381,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3444,7 +3394,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4221,7 +4170,7 @@
|
||||
"version": "24.10.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.15.tgz",
|
||||
"integrity": "sha512-BgjLoRuSr0MTI5wA6gMw9Xy0sFudAaUuvrnjgGx9wZ522fYYLA5SYJ+1Y30vTcJEG+DRCyDHx/gzQVfofYzSdg==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
@@ -4257,14 +4206,14 @@
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
|
||||
"integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
@@ -4792,7 +4741,7 @@
|
||||
"version": "8.16.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
@@ -5179,7 +5128,7 @@
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
@@ -5343,7 +5292,7 @@
|
||||
"version": "2.20.3",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
|
||||
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
@@ -5726,7 +5675,7 @@
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
@@ -6214,7 +6163,6 @@
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@@ -6338,7 +6286,7 @@
|
||||
"version": "20.7.0",
|
||||
"resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.7.0.tgz",
|
||||
"integrity": "sha512-hR/uLYQdngTyEfxnOoa+e6KTcfBFyc1hgFj/Cc144A5JJUuHFYqIEBDcD4FeGqUeKLRZqJ9eN9u7/GDjYEgS1g==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": ">=20.0.0",
|
||||
@@ -7858,7 +7806,7 @@
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -7877,7 +7825,7 @@
|
||||
"version": "0.5.21",
|
||||
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
|
||||
"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-from": "^1.0.0",
|
||||
@@ -7979,7 +7927,7 @@
|
||||
"version": "5.46.0",
|
||||
"resolved": "https://registry.npmjs.org/terser/-/terser-5.46.0.tgz",
|
||||
"integrity": "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"@jridgewell/source-map": "^0.3.3",
|
||||
@@ -8130,7 +8078,7 @@
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
@@ -8429,7 +8377,7 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
|
||||
"integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -8482,7 +8430,7 @@
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1024 1024">
|
||||
<!-- Generator: Adobe Illustrator 29.7.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 8) -->
|
||||
<defs>
|
||||
<style>
|
||||
.st0 {
|
||||
fill: #877cd6;
|
||||
}
|
||||
|
||||
.st1 {
|
||||
fill: #6e65d8;
|
||||
}
|
||||
|
||||
.st2 {
|
||||
fill: url(#linear-gradient2);
|
||||
}
|
||||
|
||||
.st3 {
|
||||
fill: #6e63d7;
|
||||
}
|
||||
|
||||
.st4 {
|
||||
fill: #b1abf0;
|
||||
}
|
||||
|
||||
.st5 {
|
||||
fill: url(#linear-gradient1);
|
||||
}
|
||||
|
||||
.st6 {
|
||||
fill: #a39ae9;
|
||||
}
|
||||
|
||||
.st7 {
|
||||
fill: #8476d5;
|
||||
}
|
||||
|
||||
.st8 {
|
||||
fill: #aaa2eb;
|
||||
}
|
||||
|
||||
.st9 {
|
||||
fill: #675bd2;
|
||||
}
|
||||
|
||||
.st10 {
|
||||
fill: #b9b4f1;
|
||||
}
|
||||
|
||||
.st11 {
|
||||
fill: #5449b9;
|
||||
}
|
||||
|
||||
.st12 {
|
||||
fill: #655acd;
|
||||
}
|
||||
|
||||
.st13 {
|
||||
fill: #b6b1f0;
|
||||
}
|
||||
|
||||
.st14 {
|
||||
fill: #9e93eb;
|
||||
}
|
||||
|
||||
.st15 {
|
||||
fill: #a298e8;
|
||||
}
|
||||
|
||||
.st16 {
|
||||
fill: #4239a5;
|
||||
}
|
||||
|
||||
.st17 {
|
||||
fill: #b0a9ed;
|
||||
}
|
||||
|
||||
.st18 {
|
||||
fill: #3d35a1;
|
||||
}
|
||||
|
||||
.st19 {
|
||||
fill: #8f80e4;
|
||||
}
|
||||
|
||||
.st20 {
|
||||
fill: #9186f3;
|
||||
}
|
||||
|
||||
.st21 {
|
||||
fill: #5a4ec2;
|
||||
}
|
||||
|
||||
.st22 {
|
||||
fill: #423aa4;
|
||||
}
|
||||
|
||||
.st23 {
|
||||
fill: #8e81dc;
|
||||
}
|
||||
|
||||
.st24 {
|
||||
fill: url(#linear-gradient);
|
||||
}
|
||||
|
||||
.st25 {
|
||||
fill: #594ec0;
|
||||
}
|
||||
|
||||
.st26 {
|
||||
fill: #9487e7;
|
||||
}
|
||||
</style>
|
||||
<linearGradient id="linear-gradient" x1="945.85" y1="1577.02" x2="761.14" y2="1748.14" gradientTransform="translate(-268 -1012)" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#7367dc"/>
|
||||
<stop offset="1" stop-color="#4e44b1"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="linear-gradient1" x1="722.68" y1="1595.53" x2="596.26" y2="1731.01" gradientTransform="translate(-268 -1012)" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#5b53c3"/>
|
||||
<stop offset="1" stop-color="#392e91"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="linear-gradient2" x1="948.55" y1="1362.8" x2="635.29" y2="1790.01" gradientTransform="translate(-268 -1012)" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#887ded"/>
|
||||
<stop offset="1" stop-color="#342a89"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<polygon class="st20" points="681.31 447.54 681.31 467.16 481.77 437.1 483.48 417.01 681.31 447.54"/>
|
||||
<path class="st18" d="M555.67,521.86c4.67-.95,15.46,7.01,23.68,7.48,10.63.6,19.08-6.88,26.17-5.47,5.58,1.11,5.41,7.21,2.27,10.95-11.39,13.58-43.92,11.42-55.13-1.69-3.59-4.2-2.96-10.06,3-11.27Z"/>
|
||||
<circle class="st16" cx="528.3" cy="506.02" r="13.52"/>
|
||||
<circle class="st22" cx="632.44" cy="508.64" r="13.43"/>
|
||||
<g>
|
||||
<path class="st24" d="M712.92,596.88v110.09l-234.36,18.53c-1.16-49.03-1.16-98.08,0-147.15.23-.82-.31-2.2.05-3.19,21.9,1.33,43.96,3.31,65.89,5.38,7.24.68,14.5,1.49,21.8,2.18,48.86,4.63,97.74,9.75,146.62,14.16Z"/>
|
||||
<g>
|
||||
<path class="st3" d="M712.92,706.97v-110.09,110.09Z"/>
|
||||
<path class="st4" d="M684.58,612.14c1.03,24.3,1.03,48.64,0,73.03-.26,1.25.34,2.95-.05,4.3-7.86,1.68-15.92,1.09-23.93,1.16v-81.75c4.86,1.32,21.04.41,23.98,3.27Z"/>
|
||||
<path class="st17" d="M531.97,596.88v68.67c-.15.54-.33,1.07-.6,1.55-1.78,3.15-25.19-1.98-24.47-9.18v-62.13c2.91-2.93,19.92,2.34,25.07,1.09Z"/>
|
||||
<path class="st13" d="M583.21,602.33c1.01,21.29,1.14,42.65.39,64.08l-1.56.27c-6.01-4.73-13.02-8.71-20.97-7.04l-1.85-1.71v-57.77c.85-.85,22.56,1.51,23.98,2.18Z"/>
|
||||
<path class="st10" d="M634.44,606.69v52.32l-2.45.71c-5.32-4.68-12.12-9.52-19.58-7.78l-1.95-1.65v-45.78l23.98,2.18Z"/>
|
||||
<path class="st8" d="M634.44,659.01v32.7l-23.98,1.09v-42.51c8.78-3.35,17.83,2.97,23.98,8.72Z"/>
|
||||
<path class="st6" d="M572.31,694.98l-13.63.35.55-37.41c7.76-2.78,18.95,1.52,23.97,7.63v-63.22c4.08,1.92-.83,79.47,1.1,89.93-.76,4.34-8.2,2.64-11.99,2.72Z"/>
|
||||
<path class="st15" d="M531.97,665.55c.59,10.18.59,20.36,0,30.52-.66,2.02-12.84.95-15.85,1.04-2.38.07-8.37,2.53-9.22.05v-39.24c5.92,5.49,17.26,8.87,25.07,7.63Z"/>
|
||||
<path class="st12" d="M559.23,657.92c.05,12.34-.04,24.73,0,37.07,4.35-.07,8.74.09,13.09,0l-14.18,1.1,1.1-95.93c.06,19.24-.08,38.53,0,57.77Z"/>
|
||||
<path class="st12" d="M506.9,657.92c.05,13.06-.04,26.18,0,39.24v-101.37c.06,20.69-.08,41.44,0,62.13Z"/>
|
||||
<path class="st12" d="M531.97,696.07c1.19-3.63-.03-24.65,0-30.52.14-22.87-.1-45.8,0-68.67v99.19Z"/>
|
||||
<path class="st3" d="M610.46,650.29c.05,14.15-.04,28.36,0,42.51v-88.29c.04,15.24-.05,30.54,0,45.78Z"/>
|
||||
<path class="st3" d="M634.44,691.71c.02-10.88-.03-21.82,0-32.7.05-17.42-.03-34.9,0-52.32v85.02Z"/>
|
||||
<path class="st3" d="M684.58,685.17v-73.03c.86.84,1.32,1.19,1.15,2.61-2.14,21.8,3.15,49.6-1.15,70.42Z"/>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<polygon class="st5" points="453.49 725.5 306.33 705.88 306.28 601.87 453.49 573.99 453.49 725.5"/>
|
||||
<g>
|
||||
<path class="st23" d="M408.8,608.87c2.53-2.96,13.34-2.52,17.43-4.36.24,7.3.9,14.48,1.1,21.8l-.66,68.12-8.06-.54c-2.16.07-4.37.04-6.54,0l-3.11-.93c-.93-27.55-1.62-56.31-.16-84.09Z"/>
|
||||
<path class="st0" d="M387,611.05v80.66c-3.62-2.19-15.34.22-16.96-2.11l-.33-74.59c.96-2.88,13.95-2.4,17.29-3.96Z"/>
|
||||
<polygon class="st7" points="348.85 617.59 348.85 688.44 332.5 687.35 332.5 619.77 348.85 617.59"/>
|
||||
<path class="st11" d="M408.8,608.87l-.09,82.37,3.36,2.65c-1.45-.03-2.92.04-4.37,0,1.53-10.81-2.44-80.9,1.1-85.03Z"/>
|
||||
<path class="st11" d="M427.33,626.31c.63,22.84-.47,45.83,0,68.68l-8.73-1.09c2.53-.08,5.1.06,7.64,0l1.08-67.59Z"/>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<path class="st2" d="M336.86,517.31c.08-12.89-1.57-31.04-.06-43.12.15-1.2-.08-2.43,1.14-3.22l129.72-55.03,1.08,123.49,39.25,13.84c-16.1,1.65-32.37,1.74-48.49,3.29-54.98,5.3-109.8,17.26-163.87,27.97-2.52-.29-5.64,5.91-5.64,7.44v106.28c-44.32-.62-86.36-25.78-106.3-65.38-37.11-73.67,8.88-159.45,89.48-171.08,5.05-70.54,77.25-114.81,143.6-93.75,29.07-38.59,75.03-64,123.39-68.73,87.07-8.53,171.01,51.08,190.76,136.23,1.53,6.58,5.31,22.98,4.99,28.91-.31,5.95-8.43,6.69-11,11.42,76.86-14.03,142.15,54.55,125.94,130.85-11.63,54.71-59.12,92.08-115.04,91.52l-1.09-119.9-68.67-12c.38-2.1,2.28-1.8,3.76-2.22,3.68-1.06,27.53-4.67,27.91-6.07l-.04-116.53c-.1-3.45-2.21-6.47-5.46-7.62l-217.45-34.36c-50.26,17.71-99.5,38.5-149.08,58.09-3.38,1.55-4.24,4.11-5.18,7.35-1.1,30.52-1.1,61.04,0,91.56,1,1.09.36,1.02,2.17.66,1.57-.31,12.26-3.46,13.09-3.93,1.12-11.83,1.48-23.82,1.09-35.97Z"/>
|
||||
<g>
|
||||
<path class="st1" d="M735.81,698.25l-1.09-119.9,1.09,119.9Z"/>
|
||||
<path class="st25" d="M320.51,556.55c-.76-.83-1.38-1.13-1.15-2.62,2.09-27.37-2.57-58.33-.02-85.26.21-2.19.83-2.53,1.17-3.69v91.56Z"/>
|
||||
<path class="st14" d="M441.5,456.26v85.02c-2.84,1.69-12.57,1.65-16.35,2.18v-81.21c3.7-2.31,9.24-5.16,13.35-6.37,1.09-.32,2.59-1.31,3,.37Z"/>
|
||||
<path class="st26" d="M404.44,470.43v76.3c-5.3,1.35-10.21.87-15.26,3.27v-75.21c5.46-.27,9.78-4.76,15.26-4.36Z"/>
|
||||
<path class="st19" d="M370.65,552.19c-3.11,1.45-9.5.64-13.08,2.18v-66.49c0-.61,10.85-4.28,11.46-4.36,2.82-.39,1.19,1.72,1.62,3.27,1.04,21.85,1.04,43.65,0,65.4Z"/>
|
||||
<path class="st9" d="M441.5,541.29v-85.02,85.02Z"/>
|
||||
<path class="st21" d="M370.65,552.19v-65.4c4.6,16.83-1.22,44.53,1.15,62.76-.16,1.29.36,1.94-1.15,2.64Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.2 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ToastProvider } from '@/shared/components';
|
||||
import App from './App';
|
||||
|
||||
function renderApp(initialPath: string) {
|
||||
@@ -16,9 +17,11 @@ function renderApp(initialPath: string) {
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={[initialPath]}>
|
||||
<App />
|
||||
</MemoryRouter>
|
||||
<ToastProvider>
|
||||
<MemoryRouter initialEntries={[initialPath]}>
|
||||
<App />
|
||||
</MemoryRouter>
|
||||
</ToastProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
@@ -29,14 +32,14 @@ describe('App smoke routes', () => {
|
||||
|
||||
expect(await screen.findByRole('heading', { name: /^projects$/i })).toBeInTheDocument();
|
||||
expect(screen.getByText(/demo mode active/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/visual topology mapping and real-time observability/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/deploy, manage, and monitor containerized services/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders builds page in demo mode', async () => {
|
||||
renderApp('/builds?demo=1');
|
||||
|
||||
expect(await screen.findByRole('heading', { name: /build pipeline/i })).toBeInTheDocument();
|
||||
expect(screen.getByText(/offline/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/polling/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders templates page in demo mode', async () => {
|
||||
|
||||
@@ -1,22 +1,35 @@
|
||||
import { NavLink, Outlet, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
FolderKanban,
|
||||
Hammer,
|
||||
LayoutTemplate,
|
||||
ChartBar,
|
||||
Users,
|
||||
Settings,
|
||||
BookOpen,
|
||||
Container,
|
||||
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Bell,
|
||||
BookOpen,
|
||||
ChartBar,
|
||||
CheckCircle2,
|
||||
Container,
|
||||
FolderKanban,
|
||||
LayoutTemplate,
|
||||
LogOut,
|
||||
Moon,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Zap,
|
||||
Activity,
|
||||
Settings,
|
||||
Sun,
|
||||
UploadCloud,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
getUpgradeStatus,
|
||||
listAuditLogs,
|
||||
listBuilds,
|
||||
pullUpgradeImage,
|
||||
} from '@/lib/api-client';
|
||||
import { signOutAuthSession } from '@/lib/auth-client';
|
||||
import { useAuthSession } from '@/lib/use-auth-session';
|
||||
import { useToast } from '@/shared/components';
|
||||
|
||||
const navItems = [
|
||||
{ label: 'Projects', href: '/projects', icon: FolderKanban },
|
||||
{ label: 'Builds', href: '/builds', icon: Hammer },
|
||||
{ label: 'Templates', href: '/templates', icon: LayoutTemplate },
|
||||
{ label: 'Usage', href: '/usage', icon: ChartBar },
|
||||
{ label: 'People', href: '/people', icon: Users },
|
||||
@@ -24,240 +37,299 @@ const navItems = [
|
||||
{ label: 'Docs', href: '/docs', icon: BookOpen },
|
||||
];
|
||||
|
||||
// Developer/Demo pages
|
||||
const demoNavItems = [
|
||||
{ label: 'Metrics Demo', href: '/metrics-demo', icon: Activity },
|
||||
{ label: 'Showcase', href: '/showcase', icon: Zap },
|
||||
];
|
||||
type ThemeMode = 'dark' | 'light';
|
||||
|
||||
function getInitialTheme(): ThemeMode {
|
||||
if (typeof window === 'undefined') {
|
||||
return 'dark';
|
||||
}
|
||||
return localStorage.getItem('containr.theme') === 'light' ? 'light' : 'dark';
|
||||
}
|
||||
|
||||
export function PlatformShell() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToast();
|
||||
const isDemoMode = new URLSearchParams(location.search).get('demo') === '1';
|
||||
const href = (target: string) => (isDemoMode ? `${target}?demo=1` : target);
|
||||
const [theme, setTheme] = useState<ThemeMode>(() => getInitialTheme());
|
||||
const [notificationsOpen, setNotificationsOpen] = useState(false);
|
||||
|
||||
const isActiveRoute = (itemHref: string) =>
|
||||
const sessionQuery = useAuthSession({ enabled: !isDemoMode });
|
||||
const upgradeQuery = useQuery({
|
||||
queryKey: ['upgrade-status'],
|
||||
queryFn: getUpgradeStatus,
|
||||
enabled: !isDemoMode,
|
||||
});
|
||||
const buildsQuery = useQuery({
|
||||
queryKey: ['shell-build-notifications'],
|
||||
queryFn: () => listBuilds({ page: 1, limit: 5 }),
|
||||
enabled: !isDemoMode,
|
||||
});
|
||||
const auditQuery = useQuery({
|
||||
queryKey: ['shell-audit-notifications'],
|
||||
queryFn: () => listAuditLogs({ page: 1, limit: 5 }),
|
||||
enabled: !isDemoMode,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.theme = theme;
|
||||
localStorage.setItem('containr.theme', theme);
|
||||
}, [theme]);
|
||||
|
||||
const pullMutation = useMutation({
|
||||
mutationFn: pullUpgradeImage,
|
||||
onSuccess: (status) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['upgrade-status'] });
|
||||
showToast('success', 'Upgrade image pulled', status.digest || status.imageRef || status.message);
|
||||
},
|
||||
onError: (error) => {
|
||||
showToast('error', 'Upgrade failed', error instanceof Error ? error.message : 'Unable to pull image');
|
||||
},
|
||||
});
|
||||
|
||||
const notifications = useMemo(() => {
|
||||
const builds = (buildsQuery.data?.builds ?? []).map((build) => ({
|
||||
id: `build-${build.id}`,
|
||||
title: `${build.status} build`,
|
||||
body: build.imageName || build.serviceId || build.id,
|
||||
}));
|
||||
const audits = (auditQuery.data ?? []).map((log) => ({
|
||||
id: `audit-${log.id}`,
|
||||
title: log.action,
|
||||
body: `${log.resource}${log.resourceId ? ` / ${log.resourceId}` : ''}`,
|
||||
}));
|
||||
return [...builds, ...audits].slice(0, 6);
|
||||
}, [auditQuery.data, buildsQuery.data?.builds]);
|
||||
|
||||
const signOut = async () => {
|
||||
try {
|
||||
await signOutAuthSession();
|
||||
} finally {
|
||||
queryClient.clear();
|
||||
navigate('/auth/sign-in', { replace: true });
|
||||
}
|
||||
};
|
||||
|
||||
const isActiveRoute = (itemHref: string) =>
|
||||
location.pathname === itemHref || location.pathname.startsWith(`${itemHref}/`);
|
||||
|
||||
const userName = sessionQuery.data?.user.name ?? 'Account';
|
||||
const userEmail = sessionQuery.data?.user.email ?? 'Local session';
|
||||
const initials = userName
|
||||
.split(' ')
|
||||
.map((part) => part[0])
|
||||
.join('')
|
||||
.slice(0, 2)
|
||||
.toUpperCase() || 'C';
|
||||
|
||||
return (
|
||||
<div className="app-shell min-h-screen">
|
||||
{/* Ambient glow background */}
|
||||
<div className="ambient-glow" />
|
||||
|
||||
<div className="flex min-h-screen relative">
|
||||
{/* Desktop Sidebar - self.html exact match: 64px width */}
|
||||
<aside
|
||||
className="hidden md:flex shrink-0 flex-col items-center border-r"
|
||||
style={{
|
||||
width: '64px',
|
||||
background: '#111217',
|
||||
borderRight: '1px solid rgba(255,255,255,0.07)',
|
||||
padding: '16px 0',
|
||||
gap: '5px'
|
||||
|
||||
<div className="relative flex min-h-screen">
|
||||
<aside
|
||||
className="hidden h-screen shrink-0 flex-col border-r md:flex"
|
||||
style={{
|
||||
width: '232px',
|
||||
background: 'var(--bg-base)',
|
||||
borderRightColor: 'var(--border-subtle)',
|
||||
}}
|
||||
>
|
||||
{/* Logo - exact self.html: 38px circular pink */}
|
||||
<div
|
||||
className="rounded-full flex items-center justify-center"
|
||||
style={{
|
||||
width: '38px',
|
||||
height: '38px',
|
||||
background: '#e8316a',
|
||||
marginBottom: '14px'
|
||||
}}
|
||||
<NavLink
|
||||
to={href('/projects')}
|
||||
className="flex h-[58px] shrink-0 items-center gap-3 border-b px-4"
|
||||
style={{ borderBottomColor: 'var(--border-subtle)' }}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="white">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 14H9V8h2v8zm4 0h-2V8h2v8z"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Nav Items - 40x40 icon-only */}
|
||||
{navItems.map((item) => {
|
||||
const isActive = isActiveRoute(item.href);
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<NavLink
|
||||
key={item.href}
|
||||
to={href(item.href)}
|
||||
className={`nav-item ${isActive ? 'active' : ''}`}
|
||||
title={item.label}
|
||||
>
|
||||
<Icon size={18} />
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Separator */}
|
||||
<div style={{ width: '32px', height: '1px', background: 'rgba(255,255,255,0.07)', margin: '8px 0' }} />
|
||||
|
||||
{/* Demo/Developer Items */}
|
||||
{demoNavItems.map((item) => {
|
||||
const isActive = isActiveRoute(item.href);
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<NavLink
|
||||
key={item.href}
|
||||
to={href(item.href)}
|
||||
className={`nav-item ${isActive ? 'active' : ''}`}
|
||||
title={item.label}
|
||||
>
|
||||
<Icon size={18} />
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Spacer */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Bottom icons - Settings */}
|
||||
<NavLink to="/settings" className="nav-item" title="Settings">
|
||||
<Settings size={18} />
|
||||
<img src="/containr.svg" alt="Containr" className="h-9 w-9 rounded-[var(--radius-md)]" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-[var(--text-primary)]">Containr</p>
|
||||
<p className="text-[11px] text-[var(--text-tertiary)]">Deploy platform</p>
|
||||
</div>
|
||||
</NavLink>
|
||||
<NavLink to="/docs" className="nav-item" title="Help">
|
||||
<BookOpen size={18} />
|
||||
</NavLink>
|
||||
|
||||
{/* User Avatar - exact self.html: 34px */}
|
||||
<div
|
||||
className="rounded-full flex items-center justify-center cursor-pointer"
|
||||
style={{
|
||||
width: '34px',
|
||||
height: '34px',
|
||||
background: '#22233a',
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
color: '#9295a4',
|
||||
marginTop: '4px',
|
||||
letterSpacing: '-0.3px'
|
||||
}}
|
||||
title="User"
|
||||
>
|
||||
w.
|
||||
|
||||
<nav className="flex min-h-0 flex-1 flex-col gap-1 px-3 py-4">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = isActiveRoute(item.href);
|
||||
return (
|
||||
<NavLink
|
||||
key={item.href}
|
||||
to={href(item.href)}
|
||||
className={`flex h-10 items-center gap-3 rounded-[var(--radius-md)] px-3 text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? 'bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]'
|
||||
: 'text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)]'
|
||||
}`}
|
||||
>
|
||||
<Icon size={17} />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="shrink-0 border-t p-3" style={{ borderTopColor: 'var(--border-subtle)' }}>
|
||||
<NavLink
|
||||
to={href('/settings')}
|
||||
className="mb-2 flex items-center gap-3 rounded-[var(--radius-md)] p-2 hover:bg-[var(--surface-muted)]"
|
||||
>
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-[var(--surface-card-hover)] text-xs font-semibold text-[var(--text-primary)]">
|
||||
{initials}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-[var(--text-primary)]">{userName}</p>
|
||||
<p className="truncate text-xs text-[var(--text-tertiary)]">{userEmail}</p>
|
||||
</div>
|
||||
</NavLink>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<NavLink
|
||||
to={href('/settings')}
|
||||
className="inline-flex h-9 items-center justify-center gap-2 rounded-[var(--radius-md)] border text-xs font-medium text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
style={{ borderColor: 'var(--border-subtle)' }}
|
||||
>
|
||||
<Settings size={14} />
|
||||
Settings
|
||||
</NavLink>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void signOut()}
|
||||
className="inline-flex h-9 items-center justify-center gap-2 rounded-[var(--radius-md)] border text-xs font-medium text-[var(--error)] hover:bg-[var(--error-soft)]"
|
||||
style={{ borderColor: 'var(--error-soft)' }}
|
||||
>
|
||||
<LogOut size={14} />
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="min-w-0 flex-1 relative z-10 flex flex-col min-h-screen overflow-hidden">
|
||||
{/* Topbar - self.html exact match: 52px height */}
|
||||
<header
|
||||
className="shrink-0 flex items-center"
|
||||
style={{
|
||||
height: '52px',
|
||||
background: '#111217',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.07)',
|
||||
padding: '0 22px',
|
||||
gap: '14px'
|
||||
<div className="relative z-10 flex min-h-screen min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<header
|
||||
className="hidden h-[58px] shrink-0 items-center border-b px-5 md:flex"
|
||||
style={{
|
||||
background: 'var(--bg-base)',
|
||||
borderBottomColor: 'var(--border-subtle)',
|
||||
}}
|
||||
>
|
||||
{/* Search Box */}
|
||||
<div className="search-box">
|
||||
<NavLink to={href('/projects')} className="mr-4 flex items-center gap-2 text-[var(--text-primary)]">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-[var(--radius-md)] border border-[var(--border-subtle)]">
|
||||
<Container size={15} />
|
||||
</div>
|
||||
<span className="text-sm font-semibold">Containr</span>
|
||||
</NavLink>
|
||||
|
||||
<div className="search-box max-w-[420px]">
|
||||
<Search size={14} />
|
||||
<input type="text" placeholder="Search logs..." />
|
||||
<input type="text" placeholder="Search projects, builds, logs..." />
|
||||
</div>
|
||||
|
||||
{/* Right side */}
|
||||
<div className="ml-auto flex items-center" style={{ gap: '8px' }}>
|
||||
<button
|
||||
className="rounded-[9px] border bg-transparent text-[#9295a4] font-medium cursor-pointer"
|
||||
style={{
|
||||
height: '32px',
|
||||
padding: '0 14px',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
fontSize: '13px',
|
||||
fontFamily: 'inherit'
|
||||
}}
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTheme((current) => (current === 'dark' ? 'light' : 'dark'))}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-[var(--radius-md)] border text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
style={{ borderColor: 'var(--border-subtle)' }}
|
||||
title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
>
|
||||
Support
|
||||
{theme === 'dark' ? <Sun size={15} /> : <Moon size={15} />}
|
||||
</button>
|
||||
<button
|
||||
className="rounded-[9px] border-none flex items-center text-[#e8e9f0] font-medium cursor-pointer"
|
||||
style={{
|
||||
height: '32px',
|
||||
padding: '0 14px',
|
||||
background: 'rgba(255,255,255,0.08)',
|
||||
fontSize: '13px',
|
||||
fontFamily: 'inherit',
|
||||
gap: '6px'
|
||||
}}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => pullMutation.mutate()}
|
||||
disabled={isDemoMode || pullMutation.isPending || !upgradeQuery.data?.imageRef}
|
||||
className="inline-flex h-8 items-center gap-2 rounded-[var(--radius-md)] px-3 text-xs font-semibold text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||
style={{ background: '#e8316a' }}
|
||||
title={upgradeQuery.data?.message || 'Pull latest configured image'}
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
|
||||
<polyline points="17 11 12 6 7 11"/>
|
||||
<polyline points="17 18 12 13 7 18"/>
|
||||
</svg>
|
||||
{pullMutation.isPending ? <RefreshCw size={14} className="animate-spin" /> : <UploadCloud size={14} />}
|
||||
Upgrade
|
||||
</button>
|
||||
<button
|
||||
className="rounded-[9px] border flex items-center justify-center cursor-pointer bg-transparent"
|
||||
style={{
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
border: '1px solid rgba(255,255,255,0.1)'
|
||||
}}
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#9295a4" strokeWidth="2" strokeLinecap="round">
|
||||
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
|
||||
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setNotificationsOpen((open) => !open)}
|
||||
className="relative flex h-8 w-8 items-center justify-center rounded-[var(--radius-md)] border text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
style={{ borderColor: 'var(--border-subtle)' }}
|
||||
aria-label="Notifications"
|
||||
>
|
||||
<Bell size={15} />
|
||||
{notifications.length > 0 ? (
|
||||
<span className="absolute right-1.5 top-1.5 h-2 w-2 rounded-full bg-[var(--accent-primary)]" />
|
||||
) : null}
|
||||
</button>
|
||||
|
||||
{notificationsOpen ? (
|
||||
<div className="absolute right-0 top-10 z-50 w-80 rounded-[var(--radius-lg)] border border-[var(--border-subtle)] bg-[var(--surface-card)] p-3 shadow-2xl">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-[var(--text-primary)]">Notifications</p>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">Build and audit activity</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['shell-build-notifications'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['shell-audit-notifications'] });
|
||||
}}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-[var(--radius-sm)] text-[var(--text-tertiary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)]"
|
||||
aria-label="Refresh notifications"
|
||||
>
|
||||
<RefreshCw size={13} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{notifications.length > 0 ? (
|
||||
notifications.map((item) => (
|
||||
<div key={item.id} className="rounded-[var(--radius-md)] bg-[var(--surface-muted)] p-3">
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">{item.title}</p>
|
||||
<p className="mt-1 truncate text-xs text-[var(--text-tertiary)]">{item.body}</p>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="flex items-center gap-2 rounded-[var(--radius-md)] bg-[var(--surface-muted)] p-3 text-sm text-[var(--text-secondary)]">
|
||||
<CheckCircle2 size={15} className="text-[var(--success)]" />
|
||||
No recent events
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Mobile Header */}
|
||||
<header className="md:hidden sticky top-0 z-50 border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/70 backdrop-blur-2xl">
|
||||
<header className="md:hidden sticky top-0 z-50 border-b border-[var(--border-subtle)] bg-[var(--bg-base)]/85 backdrop-blur-2xl">
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="rounded-xl flex items-center justify-center"
|
||||
style={{
|
||||
width: '36px',
|
||||
height: '36px',
|
||||
background: '#e8316a'
|
||||
}}
|
||||
>
|
||||
<Container size={16} className="text-white" />
|
||||
<NavLink to={href('/projects')} className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-white">
|
||||
<Container size={16} />
|
||||
</div>
|
||||
<span className="font-headline font-semibold text-[var(--text-primary)]">Containr</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--text-tertiary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-muted)] transition-all">
|
||||
<Search size={16} />
|
||||
</button>
|
||||
<button className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--text-tertiary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-muted)] transition-all relative">
|
||||
<Bell size={16} />
|
||||
<div className="absolute top-1.5 right-1.5 w-2 h-2 rounded-full bg-[var(--accent-primary)]" />
|
||||
</button>
|
||||
</div>
|
||||
</NavLink>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTheme((current) => (current === 'dark' ? 'light' : 'dark'))}
|
||||
className="h-9 w-9 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-[var(--text-secondary)]"
|
||||
>
|
||||
{theme === 'dark' ? <Sun size={15} className="mx-auto" /> : <Moon size={15} className="mx-auto" />}
|
||||
</button>
|
||||
</div>
|
||||
<nav className="flex gap-1 px-3 pb-3 overflow-x-auto scrollbar-hide">
|
||||
<nav className="flex gap-1 overflow-x-auto px-3 pb-3">
|
||||
{navItems.map((item) => {
|
||||
const isActive = isActiveRoute(item.href);
|
||||
const Icon = item.icon;
|
||||
const isActive = isActiveRoute(item.href);
|
||||
return (
|
||||
<NavLink
|
||||
key={item.href}
|
||||
to={href(item.href)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium whitespace-nowrap transition-all ${
|
||||
className={`flex items-center gap-1.5 whitespace-nowrap rounded-full px-3 py-1.5 text-xs font-medium transition-all ${
|
||||
isActive
|
||||
? 'bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]'
|
||||
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-muted)]'
|
||||
}`}
|
||||
>
|
||||
<Icon size={14} />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
{/* Demo items */}
|
||||
{demoNavItems.map((item) => {
|
||||
const isActive = isActiveRoute(item.href);
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<NavLink
|
||||
key={item.href}
|
||||
to={href(item.href)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium whitespace-nowrap transition-all ${
|
||||
isActive
|
||||
? 'bg-[var(--accent-primary-soft)] text-[var(--accent-primary)]'
|
||||
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-muted)]'
|
||||
: 'text-[var(--text-secondary)] hover:bg-[var(--surface-muted)] hover:text-[var(--text-primary)]'
|
||||
}`}
|
||||
>
|
||||
<Icon size={14} />
|
||||
@@ -268,7 +340,6 @@ export function PlatformShell() {
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{/* Page Content - scrollable */}
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { Link, Navigate, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { AlertCircle, ArrowRight, Github, Gitlab, KeyRound, Loader2, Mail, Shield, User2, Workflow } from 'lucide-react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { AlertCircle, ArrowRight, Github, Loader2, Mail, User2 } from 'lucide-react';
|
||||
import {
|
||||
AuthError,
|
||||
startBitbucketSignIn,
|
||||
startGitLabSignIn,
|
||||
requestMagicLinkInvite,
|
||||
getAuthBootstrap,
|
||||
startGoogleSignIn,
|
||||
signInWithEmail,
|
||||
signUpWithEmail,
|
||||
startGiteaSignIn,
|
||||
startGitHubSignIn,
|
||||
} from '@/lib/auth-client';
|
||||
import { useAuthSession } from '@/lib/use-auth-session';
|
||||
@@ -48,7 +46,7 @@ function AuthCanvas() {
|
||||
Better Auth
|
||||
</h1>
|
||||
<p className="mt-4 max-w-md text-sm leading-relaxed text-[var(--text-secondary)]">
|
||||
Email/password, invite magic links, and OAuth providers (GitHub, GitLab, Bitbucket, Gitea) are handled by a dedicated Better Auth service with cookie sessions.
|
||||
Email/password plus GitHub and Google sign-in run through secure cookie sessions. First account bootstraps platform ownership; every later user is created from inside Containr.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -132,18 +130,24 @@ export function SignInPage() {
|
||||
const redirectPath = useMemo(() => sanitizeRedirect(searchParams.get('redirect')), [searchParams]);
|
||||
|
||||
const sessionQuery = useAuthSession();
|
||||
const bootstrapQuery = useQuery({
|
||||
queryKey: ['auth-bootstrap'],
|
||||
queryFn: getAuthBootstrap,
|
||||
});
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [magicEmail, setMagicEmail] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [info, setInfo] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isMagicLoading, setIsMagicLoading] = useState(false);
|
||||
|
||||
if (sessionQuery.data) {
|
||||
return <Navigate to={redirectPath} replace />;
|
||||
}
|
||||
|
||||
if (bootstrapQuery.data?.mode === 'register') {
|
||||
return <Navigate to={`/auth/sign-up?redirect=${encodeURIComponent(redirectPath)}`} replace />;
|
||||
}
|
||||
|
||||
const submitEmailPassword = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
@@ -162,48 +166,32 @@ export function SignInPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const submitMagicLink = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
const signInWithGitHubProvider = async () => {
|
||||
setError(null);
|
||||
setInfo(null);
|
||||
setIsMagicLoading(true);
|
||||
|
||||
try {
|
||||
await requestMagicLinkInvite(magicEmail.trim(), buildOAuthCallbackURL(redirectPath));
|
||||
setInfo('Magic invite link sent. Check your email inbox.');
|
||||
await startGitHubSignIn(buildOAuthCallbackURL(redirectPath));
|
||||
} catch (exception) {
|
||||
const message = exception instanceof AuthError ? exception.message : 'Failed to send magic link';
|
||||
const message = exception instanceof AuthError ? exception.message : 'GitHub sign-in failed';
|
||||
setError(message);
|
||||
} finally {
|
||||
setIsMagicLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const signInWithGitHubProvider = async () => {
|
||||
const signInWithGoogleProvider = async () => {
|
||||
setError(null);
|
||||
await startGitHubSignIn(buildOAuthCallbackURL(redirectPath));
|
||||
};
|
||||
|
||||
const signInWithGiteaProvider = async () => {
|
||||
setError(null);
|
||||
await startGiteaSignIn(buildOAuthCallbackURL(redirectPath));
|
||||
};
|
||||
|
||||
const signInWithGitLabProvider = async () => {
|
||||
setError(null);
|
||||
await startGitLabSignIn(buildOAuthCallbackURL(redirectPath));
|
||||
};
|
||||
|
||||
const signInWithBitbucketProvider = async () => {
|
||||
setError(null);
|
||||
await startBitbucketSignIn(buildOAuthCallbackURL(redirectPath));
|
||||
try {
|
||||
await startGoogleSignIn(buildOAuthCallbackURL(redirectPath));
|
||||
} catch (exception) {
|
||||
const message = exception instanceof AuthError ? exception.message : 'Google sign-in failed';
|
||||
setError(message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<AuthCard title="Sign In" subtitle="Use your Containr account, invite magic link, or provider OAuth.">
|
||||
<AuthCard title="Sign In" subtitle="Use email/password, GitHub, or Google.">
|
||||
{error ? <AuthErrorNotice message={error} /> : null}
|
||||
{info ? <AuthInfoNotice message={info} /> : null}
|
||||
{bootstrapQuery.isLoading ? <AuthInfoNotice message="Checking platform access mode..." /> : null}
|
||||
|
||||
<form className="space-y-3" onSubmit={submitEmailPassword}>
|
||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">
|
||||
@@ -256,58 +244,17 @@ export function SignInPage() {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void signInWithGitLabProvider()}
|
||||
onClick={() => void signInWithGoogleProvider()}
|
||||
className="inline-flex h-10 items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm font-medium text-[var(--text-primary)] transition-colors hover:border-[var(--border-default)]"
|
||||
>
|
||||
<Gitlab size={15} />
|
||||
GitLab
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void signInWithBitbucketProvider()}
|
||||
className="inline-flex h-10 items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm font-medium text-[var(--text-primary)] transition-colors hover:border-[var(--border-default)]"
|
||||
>
|
||||
<Workflow size={15} />
|
||||
Bitbucket
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void signInWithGiteaProvider()}
|
||||
className="inline-flex h-10 items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm font-medium text-[var(--text-primary)] transition-colors hover:border-[var(--border-default)]"
|
||||
>
|
||||
<Shield size={15} />
|
||||
Gitea
|
||||
<span className="text-sm font-semibold">G</span>
|
||||
Google
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form className="mt-4 space-y-2" onSubmit={submitMagicLink}>
|
||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">
|
||||
Magic Link Invite
|
||||
<input
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
value={magicEmail}
|
||||
onChange={(event) => setMagicEmail(event.target.value)}
|
||||
required
|
||||
className="mt-2 w-full rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 py-2.5 text-sm text-[var(--text-primary)] outline-none transition-colors focus:border-[var(--accent-primary)]"
|
||||
placeholder="[email protected]"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isMagicLoading}
|
||||
className="inline-flex h-10 w-full items-center justify-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] text-sm font-medium text-[var(--text-primary)] transition-colors hover:border-[var(--border-default)] disabled:opacity-60"
|
||||
>
|
||||
{isMagicLoading ? <Loader2 size={15} className="animate-spin" /> : <KeyRound size={15} />}
|
||||
Send Invite Link
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-5 flex items-center justify-between text-xs text-[var(--text-secondary)]">
|
||||
<span>No account yet?</span>
|
||||
<Link to={`/auth/sign-up?redirect=${encodeURIComponent(redirectPath)}`} className="inline-flex items-center gap-1 text-[var(--accent-primary)] hover:underline">
|
||||
Create one <ArrowRight size={12} />
|
||||
</Link>
|
||||
<span>Need access?</span>
|
||||
<span>Ask platform owner to create account.</span>
|
||||
</div>
|
||||
</AuthCard>
|
||||
</AuthLayout>
|
||||
@@ -321,6 +268,10 @@ export function SignUpPage() {
|
||||
const redirectPath = useMemo(() => sanitizeRedirect(searchParams.get('redirect')), [searchParams]);
|
||||
|
||||
const sessionQuery = useAuthSession();
|
||||
const bootstrapQuery = useQuery({
|
||||
queryKey: ['auth-bootstrap'],
|
||||
queryFn: getAuthBootstrap,
|
||||
});
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
@@ -331,6 +282,10 @@ export function SignUpPage() {
|
||||
return <Navigate to={redirectPath} replace />;
|
||||
}
|
||||
|
||||
if (bootstrapQuery.data?.mode === 'login') {
|
||||
return <Navigate to={`/auth/sign-in?redirect=${encodeURIComponent(redirectPath)}`} replace />;
|
||||
}
|
||||
|
||||
const submitSignUp = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
@@ -350,8 +305,9 @@ export function SignUpPage() {
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<AuthCard title="Create Account" subtitle="Provision your operator account with email/password auth.">
|
||||
<AuthCard title="Create Account" subtitle="Create first platform owner account. Registration closes after bootstrap.">
|
||||
{error ? <AuthErrorNotice message={error} /> : null}
|
||||
{bootstrapQuery.isLoading ? <AuthInfoNotice message="Checking platform bootstrap state..." /> : null}
|
||||
|
||||
<form className="space-y-3" onSubmit={submitSignUp}>
|
||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">
|
||||
|
||||
@@ -126,8 +126,11 @@ export function BuildsPage() {
|
||||
[isDemoMode, buildsQuery.data?.builds],
|
||||
);
|
||||
|
||||
const subscribedBuildIds = useMemo(() => builds.map((build) => build.id), [builds]);
|
||||
const liveConnected = useBuildUpdates(subscribedBuildIds, ({ channel }) => {
|
||||
const subscribedBuildIds = useMemo(
|
||||
() => (isDemoMode ? [] : builds.map((build) => build.id)),
|
||||
[isDemoMode, builds],
|
||||
);
|
||||
const liveStatus = useBuildUpdates(subscribedBuildIds, ({ channel }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['builds-page'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['usage-builds'] });
|
||||
if (selectedBuild && channel === `build:${selectedBuild.id}`) {
|
||||
@@ -149,9 +152,6 @@ export function BuildsPage() {
|
||||
queryFn: () => getBuildLogs(selectedBuild!.id),
|
||||
});
|
||||
|
||||
const isCancellingBuild = (buildId: string): boolean =>
|
||||
cancelMutation.isPending && cancelMutation.variables === buildId;
|
||||
|
||||
// Stats
|
||||
const stats = useMemo(() => {
|
||||
const running = builds.filter(b => b.status === 'running' || b.status === 'pending').length;
|
||||
@@ -173,9 +173,9 @@ export function BuildsPage() {
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<div className={`w-2 h-2 rounded-full ${liveConnected ? 'bg-[var(--success)] animate-pulse' : 'bg-[var(--text-muted)]'}`} />
|
||||
<span className={liveConnected ? 'text-[var(--success)]' : 'text-[var(--text-muted)]'}>
|
||||
{liveConnected ? 'Live' : 'Offline'}
|
||||
<div className={`w-2 h-2 rounded-full ${liveStatus === 'live' ? 'bg-[var(--success)] animate-pulse' : 'bg-[var(--text-muted)]'}`} />
|
||||
<span className={liveStatus === 'live' ? 'text-[var(--success)]' : 'text-[var(--text-muted)]'}>
|
||||
{liveStatus === 'live' ? 'Live' : liveStatus === 'offline' ? 'Reconnecting...' : 'Polling'}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
|
||||
@@ -226,41 +226,36 @@ export function ProjectsPage() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen relative">
|
||||
{/* Hero Section - Premium ambient design */}
|
||||
<div className="relative overflow-hidden border-b border-[var(--border-subtle)]">
|
||||
{/* Solid ambient background */}
|
||||
<div className="absolute inset-0 bg-[#e8316a]/5" />
|
||||
<div className="absolute top-0 left-1/4 w-96 h-96 bg-[var(--accent-primary)]/5 rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-0 right-1/4 w-80 h-80 bg-[var(--accent-secondary)]/5 rounded-full blur-3xl" />
|
||||
|
||||
<div className="relative mx-auto w-full max-w-[1400px] px-6 py-12 md:py-16 lg:py-20">
|
||||
<div className="max-w-2xl">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div
|
||||
className="flex items-center justify-center rounded-xl shadow-lg ring-1 ring-white/10"
|
||||
style={{ width: '44px', height: '44px', background: '#e8316a' }}
|
||||
>
|
||||
<FolderOpen size={20} className="text-white" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-[var(--surface-muted)] border border-[var(--border-subtle)]">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-[var(--success)] live-pulse" />
|
||||
<span className="text-[10px] font-semibold uppercase tracking-widest text-[var(--text-tertiary)]">
|
||||
Workspace
|
||||
</span>
|
||||
</div>
|
||||
{/* Header */}
|
||||
<div className="border-b border-[var(--border-subtle)]">
|
||||
<div className="mx-auto w-full max-w-[1400px] px-6 py-5 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="flex items-center justify-center rounded-[var(--radius-md)]"
|
||||
style={{ width: '36px', height: '36px', background: '#e8316a' }}
|
||||
>
|
||||
<FolderOpen size={17} className="text-white" />
|
||||
</div>
|
||||
<h1 className="font-headline text-4xl md:text-5xl lg:text-6xl font-bold tracking-tight text-[var(--text-primary)]">
|
||||
Projects
|
||||
</h1>
|
||||
<p className="mt-4 text-lg text-[var(--text-secondary)] max-w-xl leading-relaxed">
|
||||
Deploy, manage, and monitor your containerized services with visual topology mapping and real-time observability.
|
||||
</p>
|
||||
<div>
|
||||
<h1 className="font-headline text-xl font-semibold tracking-tight text-[var(--text-primary)]">
|
||||
Projects
|
||||
</h1>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">
|
||||
Deploy, manage, and monitor containerized services
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-[var(--surface-muted)] border border-[var(--border-subtle)]">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-[var(--success)] live-pulse" />
|
||||
<span className="text-[10px] font-semibold uppercase tracking-widest text-[var(--text-tertiary)]">
|
||||
Workspace
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="mx-auto w-full max-w-[1400px] px-6 py-8">
|
||||
<div className="mx-auto w-full max-w-[1400px] px-6 py-6">
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 mb-8">
|
||||
<div className="relative flex-1">
|
||||
|
||||
@@ -3,9 +3,16 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
getApiBaseUrl,
|
||||
getAgentPublicBaseUrl,
|
||||
getHostMonitoring,
|
||||
createUser,
|
||||
createGitProvider,
|
||||
deleteGitProvider,
|
||||
getCurrentUserProfile,
|
||||
listAuditLogs,
|
||||
listAgents,
|
||||
listBuilds,
|
||||
listGitProviders,
|
||||
listProjects,
|
||||
listTemplates,
|
||||
updateCurrentUserProfile,
|
||||
@@ -21,6 +28,7 @@ import {
|
||||
FileText,
|
||||
Settings,
|
||||
User,
|
||||
UserPlus,
|
||||
Key,
|
||||
Database,
|
||||
RefreshCw,
|
||||
@@ -34,6 +42,11 @@ import {
|
||||
Box,
|
||||
Terminal,
|
||||
Radio,
|
||||
Server,
|
||||
HardDrive,
|
||||
GitBranch,
|
||||
Link2,
|
||||
Unlink,
|
||||
} from 'lucide-react';
|
||||
|
||||
function SecondaryPageHeader({ title, description }: { title: string; description: string }) {
|
||||
@@ -120,15 +133,51 @@ function endpointStateBadge(isLoading: boolean, isError: boolean): {
|
||||
return { label: 'Available', toneClass: 'text-[var(--ok)]' };
|
||||
}
|
||||
|
||||
function formatBytes(value: number): string {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return '0 B';
|
||||
}
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let size = value;
|
||||
let unitIndex = 0;
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
return `${size.toFixed(size >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
function formatUptime(seconds: number): string {
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) {
|
||||
return '—';
|
||||
}
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
if (days > 0) {
|
||||
return `${days}d ${hours}h`;
|
||||
}
|
||||
return `${hours}h ${Math.floor((seconds % 3600) / 60)}m`;
|
||||
}
|
||||
|
||||
export function UsagePage() {
|
||||
const queryClient = useQueryClient();
|
||||
const buildsQuery = useQuery({
|
||||
queryKey: ['usage-builds'],
|
||||
queryFn: () => listBuilds({ page: 1, limit: 100 }),
|
||||
});
|
||||
const hostQuery = useQuery({
|
||||
queryKey: ['usage-host-monitoring'],
|
||||
queryFn: getHostMonitoring,
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
const agentsQuery = useQuery({
|
||||
queryKey: ['usage-agents'],
|
||||
queryFn: listAgents,
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
|
||||
const builds = buildsQuery.data?.builds ?? [];
|
||||
const liveConnected = useBuildUpdates(
|
||||
const liveStatus = useBuildUpdates(
|
||||
builds.map((build) => build.id),
|
||||
() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['usage-builds'] });
|
||||
@@ -156,6 +205,15 @@ export function UsagePage() {
|
||||
const avgBuildMinutes = averageBuildDurationMs / 60_000;
|
||||
const activeBuildPressure = runningBuilds + pendingBuilds;
|
||||
const uniqueServices = new Set(builds.map((build) => build.serviceId).filter(Boolean)).size;
|
||||
const host = hostQuery.data;
|
||||
const agents = agentsQuery.data ?? [];
|
||||
const onlineAgents = agents.filter((agent) => agent.status === 'online' || agent.status === 'connecting').length;
|
||||
const totalAgentMemory = agents.reduce((sum, agent) => sum + agent.resources.memory.total, 0);
|
||||
const availableAgentMemory = agents.reduce((sum, agent) => sum + agent.resources.memory.available, 0);
|
||||
const agentEndpoint = getAgentPublicBaseUrl();
|
||||
const connectCommand = `CONTAINR_API_URL=${getApiBaseUrl().replace(/\/api\/v1$/, '')} \\
|
||||
CONTAINR_AGENT_AUTH_TOKEN=<token> \\
|
||||
containr-agent`;
|
||||
|
||||
let buildActivityBody = 'Track deployment frequency and failed rollouts over time.';
|
||||
let runtimeBody = 'Build duration telemetry will appear after completed builds are available.';
|
||||
@@ -197,9 +255,9 @@ export function UsagePage() {
|
||||
/>
|
||||
<div className="mx-auto w-full max-w-[1400px] px-6 py-6">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<div className={`w-2 h-2 rounded-full ${liveConnected ? 'bg-[var(--success)] animate-pulse' : 'bg-[var(--text-muted)]'}`} />
|
||||
<span className={`text-sm ${liveConnected ? 'text-[var(--success)]' : 'text-[var(--text-muted)]'}`}>
|
||||
{liveConnected ? 'Live sync active' : 'Offline'}
|
||||
<div className={`w-2 h-2 rounded-full ${liveStatus === 'live' ? 'bg-[var(--success)] animate-pulse' : 'bg-[var(--text-muted)]'}`} />
|
||||
<span className={`text-sm ${liveStatus === 'live' ? 'text-[var(--success)]' : 'text-[var(--text-muted)]'}`}>
|
||||
{liveStatus === 'live' ? 'Live sync active' : liveStatus === 'offline' ? 'Reconnecting...' : 'Polling for updates'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -216,28 +274,165 @@ export function UsagePage() {
|
||||
<p className="text-sm text-[var(--error)]">Failed to load usage data</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<StatCard
|
||||
title="Runtime Hours"
|
||||
value={totalBuildHours > 0 ? `${totalBuildHours.toFixed(1)}h` : '—'}
|
||||
description={runtimeBody}
|
||||
icon={Clock}
|
||||
color={completedDurationsMs.length > 0 ? 'success' : 'default'}
|
||||
/>
|
||||
<StatCard
|
||||
title="Build Activity"
|
||||
value={String(builds.length)}
|
||||
description={buildActivityBody}
|
||||
icon={Activity}
|
||||
color={failedBuilds > 0 ? 'warning' : 'success'}
|
||||
/>
|
||||
<StatCard
|
||||
title="Capacity"
|
||||
value={String(activeBuildPressure)}
|
||||
description={capacityBody}
|
||||
icon={Gauge}
|
||||
color={activeBuildPressure > 0 ? 'warning' : 'default'}
|
||||
/>
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<StatCard
|
||||
title="Runtime Hours"
|
||||
value={totalBuildHours > 0 ? `${totalBuildHours.toFixed(1)}h` : '—'}
|
||||
description={runtimeBody}
|
||||
icon={Clock}
|
||||
color={completedDurationsMs.length > 0 ? 'success' : 'default'}
|
||||
/>
|
||||
<StatCard
|
||||
title="Build Activity"
|
||||
value={String(builds.length)}
|
||||
description={buildActivityBody}
|
||||
icon={Activity}
|
||||
color={failedBuilds > 0 ? 'warning' : 'success'}
|
||||
/>
|
||||
<StatCard
|
||||
title="Capacity"
|
||||
value={String(activeBuildPressure)}
|
||||
description={capacityBody}
|
||||
icon={Gauge}
|
||||
color={activeBuildPressure > 0 ? 'warning' : 'default'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="panel p-6">
|
||||
<div className="mb-5 flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)]">
|
||||
<Server size={18} className="text-[var(--accent-primary)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Host Monitoring</h2>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">Autoscaling base signal from this Containr host.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
void hostQuery.refetch();
|
||||
void agentsQuery.refetch();
|
||||
}}
|
||||
className="inline-flex h-9 items-center gap-2 rounded-[var(--radius-md)] border border-[var(--border-subtle)] px-3 text-sm"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{hostQuery.isError ? (
|
||||
<div className="rounded-[var(--radius-md)] bg-[var(--error-soft)] px-4 py-3 text-sm text-[var(--error)]">
|
||||
Host monitoring unavailable.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||
<StatCard
|
||||
title="Host"
|
||||
value={host?.hostname || '—'}
|
||||
description={host ? `${host.os}/${host.architecture} · uptime ${formatUptime(host.uptimeSeconds)}` : 'Loading host identity.'}
|
||||
icon={Server}
|
||||
color={host?.dockerAvailable ? 'success' : 'warning'}
|
||||
/>
|
||||
<StatCard
|
||||
title="CPU Load"
|
||||
value={host ? host.load.load1m.toFixed(2) : '—'}
|
||||
description={host ? `${host.cpu.cores} cores · 5m ${host.load.load5m.toFixed(2)} · 15m ${host.load.load15m.toFixed(2)}` : 'Loading CPU load.'}
|
||||
icon={Gauge}
|
||||
color={host && host.load.load1m > host.cpu.cores ? 'warning' : 'default'}
|
||||
/>
|
||||
<StatCard
|
||||
title="Memory"
|
||||
value={host ? `${host.memory.usagePercent.toFixed(0)}%` : '—'}
|
||||
description={host ? `${formatBytes(host.memory.used)} used · ${formatBytes(host.memory.available)} free` : 'Loading memory telemetry.'}
|
||||
icon={Activity}
|
||||
color={host && host.memory.usagePercent > 80 ? 'warning' : 'success'}
|
||||
/>
|
||||
<StatCard
|
||||
title="Disk"
|
||||
value={host ? `${host.storage.usagePercent.toFixed(0)}%` : '—'}
|
||||
description={host ? `${formatBytes(host.storage.used)} used · ${formatBytes(host.storage.available)} free on ${host.storage.path}` : 'Loading disk telemetry.'}
|
||||
icon={HardDrive}
|
||||
color={host && host.storage.usagePercent > 80 ? 'warning' : 'success'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="panel p-6">
|
||||
<div className="mb-5 flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-[var(--radius-md)] bg-[var(--surface-muted)]">
|
||||
<Radio size={18} className="text-[var(--text-tertiary)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Node Agents</h2>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">Remote VPS, VM, and LXC capacity for placement and autoscaling.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[1fr_1fr]">
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<StatCard
|
||||
title="Registered"
|
||||
value={String(agents.length)}
|
||||
description={agentsQuery.isError ? 'Agent API unavailable.' : 'Nodes connected through token heartbeats.'}
|
||||
icon={Radio}
|
||||
color={agents.length > 0 ? 'success' : 'default'}
|
||||
/>
|
||||
<StatCard
|
||||
title="Online"
|
||||
value={String(onlineAgents)}
|
||||
description="Online and connecting agents can receive queued commands."
|
||||
icon={Activity}
|
||||
color={onlineAgents > 0 ? 'success' : 'default'}
|
||||
/>
|
||||
<StatCard
|
||||
title="Remote Memory"
|
||||
value={formatBytes(totalAgentMemory)}
|
||||
description={`${formatBytes(availableAgentMemory)} currently free across agents.`}
|
||||
icon={Database}
|
||||
color={totalAgentMemory > 0 ? 'success' : 'default'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-[var(--radius-md)] border border-[var(--border-subtle)]">
|
||||
{agents.length > 0 ? (
|
||||
agents.slice(0, 5).map((agent) => (
|
||||
<div key={agent.id} className="flex items-center justify-between gap-3 border-b border-[var(--border-subtle)] px-4 py-3 last:border-b-0">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-[var(--text-primary)]">{agent.name}</p>
|
||||
<p className="truncate text-xs text-[var(--text-tertiary)]">{agent.hostname} · {agent.ipAddress}:{agent.port}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-xs font-semibold text-[var(--text-primary)]">{agent.status}</p>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">{formatBytes(agent.resources.memory.available)} free</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="px-4 py-6 text-sm text-[var(--text-tertiary)]">
|
||||
No remote agents registered yet.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-void)] p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Terminal size={14} className="text-[var(--text-tertiary)]" />
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Connect Node</span>
|
||||
</div>
|
||||
<pre className="mono text-xs text-[var(--text-secondary)] whitespace-pre-wrap">{connectCommand}</pre>
|
||||
<div className="mt-4 space-y-2 text-xs text-[var(--text-tertiary)]">
|
||||
<p>Agent endpoint: <span className="mono text-[var(--text-primary)]">{agentEndpoint}</span></p>
|
||||
<p>Set <span className="mono text-[var(--text-primary)]">CONTAINR_AGENT_AUTH_TOKEN</span> to a value accepted by the backend.</p>
|
||||
<p>Agent sends host resources, polls pending Docker commands, and reports command results.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -246,10 +441,30 @@ export function UsagePage() {
|
||||
}
|
||||
|
||||
export function PeoplePage() {
|
||||
const queryClient = useQueryClient();
|
||||
const profileQuery = useQuery({
|
||||
queryKey: ['user-profile'],
|
||||
queryFn: getCurrentUserProfile,
|
||||
});
|
||||
const [newUserName, setNewUserName] = useState('');
|
||||
const [newUserEmail, setNewUserEmail] = useState('');
|
||||
const [newUserPassword, setNewUserPassword] = useState('');
|
||||
|
||||
const createUserMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
createUser({
|
||||
name: newUserName.trim(),
|
||||
email: newUserEmail.trim(),
|
||||
password: newUserPassword,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setNewUserName('');
|
||||
setNewUserEmail('');
|
||||
setNewUserPassword('');
|
||||
queryClient.invalidateQueries({ queryKey: ['user-profile'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['people-audit-logs'] });
|
||||
},
|
||||
});
|
||||
|
||||
const projectsQuery = useQuery({
|
||||
queryKey: ['people-projects'],
|
||||
@@ -312,28 +527,95 @@ export function PeoplePage() {
|
||||
<p className="mt-3 text-sm text-[var(--text-muted)]">Loading team data...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<StatCard
|
||||
title="Members"
|
||||
value={profileQuery.data ? '1' : '—'}
|
||||
description={membersBody}
|
||||
icon={Users}
|
||||
color={profileQuery.data ? 'success' : 'default'}
|
||||
/>
|
||||
<StatCard
|
||||
title="Roles"
|
||||
value={projectsQuery.data?.length ? String(projectsQuery.data.length) : '—'}
|
||||
description={rolesBody}
|
||||
icon={Shield}
|
||||
color="default"
|
||||
/>
|
||||
<StatCard
|
||||
title="Audit Trail"
|
||||
value={auditLogsQuery.data ? String(auditLogsQuery.data.length) : '—'}
|
||||
description={auditBody}
|
||||
icon={FileText}
|
||||
color={auditLogsQuery.data?.length ? 'success' : 'default'}
|
||||
/>
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<StatCard
|
||||
title="Members"
|
||||
value={profileQuery.data ? '1+' : '—'}
|
||||
description={membersBody}
|
||||
icon={Users}
|
||||
color={profileQuery.data ? 'success' : 'default'}
|
||||
/>
|
||||
<StatCard
|
||||
title="Roles"
|
||||
value={projectsQuery.data?.length ? String(projectsQuery.data.length) : '—'}
|
||||
description={rolesBody}
|
||||
icon={Shield}
|
||||
color="default"
|
||||
/>
|
||||
<StatCard
|
||||
title="Audit Trail"
|
||||
value={auditLogsQuery.data ? String(auditLogsQuery.data.length) : '—'}
|
||||
description={auditBody}
|
||||
icon={FileText}
|
||||
color={auditLogsQuery.data?.length ? 'success' : 'default'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="panel p-6">
|
||||
<div className="mb-5 flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)]">
|
||||
<UserPlus size={18} className="text-[var(--accent-primary)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Manual User Creation</h2>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">Public registration is closed after bootstrap.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="grid gap-3 lg:grid-cols-[1fr_1fr_1fr_auto]"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
createUserMutation.mutate();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
value={newUserName}
|
||||
onChange={(event) => setNewUserName(event.target.value)}
|
||||
required
|
||||
className="h-10 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm"
|
||||
placeholder="Name"
|
||||
/>
|
||||
<input
|
||||
value={newUserEmail}
|
||||
onChange={(event) => setNewUserEmail(event.target.value)}
|
||||
type="email"
|
||||
required
|
||||
className="h-10 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm"
|
||||
placeholder="[email protected]"
|
||||
/>
|
||||
<input
|
||||
value={newUserPassword}
|
||||
onChange={(event) => setNewUserPassword(event.target.value)}
|
||||
type="password"
|
||||
minLength={8}
|
||||
required
|
||||
className="h-10 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm"
|
||||
placeholder="Temporary password"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createUserMutation.isPending}
|
||||
className="inline-flex h-10 items-center justify-center gap-2 rounded-[var(--radius-md)] px-4 text-sm font-semibold text-white disabled:opacity-60"
|
||||
style={{ background: '#e8316a' }}
|
||||
>
|
||||
{createUserMutation.isPending ? <Loader2 size={14} className="animate-spin" /> : <UserPlus size={14} />}
|
||||
Create
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{createUserMutation.isError ? (
|
||||
<div className="mt-4 rounded-[var(--radius-md)] bg-[var(--error-soft)] px-4 py-3 text-sm text-[var(--error)]">
|
||||
{createUserMutation.error instanceof Error ? createUserMutation.error.message : 'Failed to create user'}
|
||||
</div>
|
||||
) : null}
|
||||
{createUserMutation.isSuccess ? (
|
||||
<div className="mt-4 rounded-[var(--radius-md)] bg-[var(--success-soft)] px-4 py-3 text-sm text-[var(--success)]">
|
||||
User created. Share credentials through secure channel.
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -341,6 +623,196 @@ export function PeoplePage() {
|
||||
);
|
||||
}
|
||||
|
||||
const gitProviderTypes = [
|
||||
{ value: 'github', label: 'GitHub', tokenHint: 'Personal access token with repo scope' },
|
||||
{ value: 'gitlab', label: 'GitLab', tokenHint: 'Personal access token with read_api scope' },
|
||||
{ value: 'gitea', label: 'Gitea', tokenHint: 'Access token from your Gitea instance' },
|
||||
{ value: 'bitbucket', label: 'Bitbucket', tokenHint: 'App password with repository read' },
|
||||
] as const;
|
||||
|
||||
function GitProvidersSection() {
|
||||
const queryClient = useQueryClient();
|
||||
const providersQuery = useQuery({
|
||||
queryKey: ['git-providers'],
|
||||
queryFn: listGitProviders,
|
||||
});
|
||||
|
||||
const [form, setForm] = useState({
|
||||
name: 'github' as (typeof gitProviderTypes)[number]['value'],
|
||||
displayName: '',
|
||||
accessToken: '',
|
||||
apiUrl: '',
|
||||
});
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
createGitProvider({
|
||||
name: form.name,
|
||||
display_name: form.displayName.trim() || gitProviderTypes.find((t) => t.value === form.name)!.label,
|
||||
access_token: form.accessToken.trim(),
|
||||
api_url: form.apiUrl.trim() || undefined,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['git-providers'] });
|
||||
setForm({ name: 'github', displayName: '', accessToken: '', apiUrl: '' });
|
||||
setFormOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (providerId: string) => deleteGitProvider(providerId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['git-providers'] });
|
||||
},
|
||||
});
|
||||
|
||||
const providers = providersQuery.data ?? [];
|
||||
const selectedType = gitProviderTypes.find((t) => t.value === form.name)!;
|
||||
|
||||
return (
|
||||
<section className="panel p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
|
||||
<GitBranch size={18} className="text-[var(--accent-primary)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Git Providers</h2>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">Connect accounts to deploy services from repositories</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setFormOpen((open) => !open)}
|
||||
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-white text-sm font-medium shadow-lg transition-all"
|
||||
style={{ background: '#e8316a' }}
|
||||
>
|
||||
<Link2 size={14} />
|
||||
{formOpen ? 'Close' : 'Connect'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{providersQuery.isLoading ? (
|
||||
<div className="py-6 text-center">
|
||||
<Loader2 size={20} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
|
||||
</div>
|
||||
) : providersQuery.isError ? (
|
||||
<div className="px-4 py-3 rounded-[var(--radius-md)] bg-[var(--error-soft)] text-sm text-[var(--error)]">
|
||||
Failed to load providers
|
||||
</div>
|
||||
) : providers.length === 0 ? (
|
||||
<div className="py-6 text-center">
|
||||
<p className="text-sm text-[var(--text-secondary)]">No Git providers connected</p>
|
||||
<p className="text-xs text-[var(--text-muted)] mt-1">
|
||||
Connect GitHub, GitLab, Gitea, or Bitbucket to deploy services from source
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{providers.map((provider) => (
|
||||
<div
|
||||
key={provider.id}
|
||||
className="flex items-center justify-between p-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-[var(--radius-sm)] bg-[var(--surface-card)] flex items-center justify-center">
|
||||
<GitBranch size={14} className="text-[var(--text-tertiary)]" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">{provider.display_name}</p>
|
||||
<p className="text-xs text-[var(--text-muted)] capitalize">{provider.name.replace('_', ' ')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => deleteMutation.mutate(provider.id)}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="flex items-center gap-2 h-8 px-3 rounded-[var(--radius-sm)] border border-[var(--error-soft)] text-[var(--error)] text-xs font-medium hover:bg-[var(--error-soft)] disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<Unlink size={12} />
|
||||
Disconnect
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formOpen && (
|
||||
<div className="mt-4 pt-4 border-t border-[var(--border-subtle)] space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
|
||||
Provider
|
||||
</label>
|
||||
<select
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((p) => ({ ...p, name: e.target.value as typeof form.name }))}
|
||||
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm focus:border-[var(--accent-primary)] transition-colors"
|
||||
>
|
||||
{gitProviderTypes.map((type) => (
|
||||
<option key={type.value} value={type.value}>{type.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
|
||||
Display Name
|
||||
</label>
|
||||
<input
|
||||
value={form.displayName}
|
||||
onChange={(e) => setForm((p) => ({ ...p, displayName: e.target.value }))}
|
||||
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm focus:border-[var(--accent-primary)] transition-colors"
|
||||
placeholder={selectedType.label}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
|
||||
Access Token
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={form.accessToken}
|
||||
onChange={(e) => setForm((p) => ({ ...p, accessToken: e.target.value }))}
|
||||
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm mono focus:border-[var(--accent-primary)] transition-colors"
|
||||
placeholder="••••••••••••••••"
|
||||
/>
|
||||
<p className="text-xs text-[var(--text-muted)] mt-1.5">{selectedType.tokenHint}</p>
|
||||
</div>
|
||||
{form.name === 'gitea' && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-muted)] mb-2">
|
||||
Instance URL
|
||||
</label>
|
||||
<input
|
||||
value={form.apiUrl}
|
||||
onChange={(e) => setForm((p) => ({ ...p, apiUrl: e.target.value }))}
|
||||
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm mono focus:border-[var(--accent-primary)] transition-colors"
|
||||
placeholder="https://gitea.example.com"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{createMutation.isError && (
|
||||
<div className="px-4 py-3 rounded-[var(--radius-md)] bg-[var(--error-soft)] text-sm text-[var(--error)]">
|
||||
{createMutation.error instanceof Error ? createMutation.error.message : 'Failed to connect provider'}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end pt-1">
|
||||
<button
|
||||
onClick={() => createMutation.mutate()}
|
||||
disabled={!form.accessToken.trim() || createMutation.isPending}
|
||||
className="flex items-center gap-2 h-9 px-4 rounded-[var(--radius-md)] text-white text-sm font-medium shadow-lg disabled:opacity-50 disabled:cursor-not-allowed transition-all"
|
||||
style={{ background: '#e8316a' }}
|
||||
>
|
||||
{createMutation.isPending ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />}
|
||||
Connect Provider
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -502,6 +974,9 @@ export function SettingsPage() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Git Providers Section */}
|
||||
<GitProvidersSection />
|
||||
|
||||
{/* Runtime & Local State Section */}
|
||||
<section className="panel p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
@@ -701,7 +1176,7 @@ export function DocsPage() {
|
||||
<FileText size={14} className="text-[var(--text-tertiary)]" />
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Generated Types</span>
|
||||
</div>
|
||||
<p className="mono text-xs text-[var(--text-primary)]">frontend/src/generated/api-types.ts</p>
|
||||
<p className="mono text-xs text-[var(--text-primary)]">app/frontend/src/generated/api-types.ts</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
@@ -715,7 +1190,7 @@ export function DocsPage() {
|
||||
<FileText size={14} className="text-[var(--text-tertiary)]" />
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">UI References</span>
|
||||
</div>
|
||||
<p className="mono text-xs text-[var(--text-primary)]">docs/references/self.html</p>
|
||||
<p className="mono text-xs text-[var(--text-primary)]">docs/references/dashboard.png • projects.png</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -735,13 +1210,16 @@ export function DocsPage() {
|
||||
<div className="p-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--bg-void)]">
|
||||
<pre className="mono text-xs text-[var(--text-secondary)] whitespace-pre-wrap">
|
||||
{`# Regenerate frontend API types
|
||||
npm --prefix frontend run generate:api
|
||||
npm --prefix app/frontend run generate:api
|
||||
|
||||
# Frontend type-check + build
|
||||
npm --prefix frontend run build:check
|
||||
npm --prefix app/frontend run build:check
|
||||
|
||||
# Backend API tests
|
||||
cd backend && go test ./internal/api/...`}
|
||||
cd app/backend && go test ./internal/api/...
|
||||
|
||||
# Build remote node agent
|
||||
cd app/backend && go build -o bin/containr-agent ./cmd/agent`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
@@ -7,21 +7,18 @@ import {
|
||||
getProjectById,
|
||||
getDeploymentLogs,
|
||||
getServiceById,
|
||||
getServiceMetrics,
|
||||
listDeployments,
|
||||
listServiceLogs,
|
||||
rollbackDeployment,
|
||||
} from '@/lib/api-client';
|
||||
import { getDemoProjectById, getDemoServiceById } from '@/lib/demo-data';
|
||||
import { formatDate, formatRelative, seededMetric } from '@/lib/time';
|
||||
import { formatBytes, formatDate, formatRelative, seededMetric } from '@/lib/time';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Activity,
|
||||
FileText,
|
||||
Settings,
|
||||
Sliders,
|
||||
Play,
|
||||
RotateCw,
|
||||
Trash2,
|
||||
Check,
|
||||
X,
|
||||
Loader2,
|
||||
@@ -171,23 +168,50 @@ export function ServiceDetailPage() {
|
||||
const project = isDemoMode ? getDemoProjectById(projectId) : projectQuery.data;
|
||||
const service = isDemoMode ? getDemoServiceById(serviceId) : serviceQuery.data;
|
||||
|
||||
const metricsQuery = useQuery({
|
||||
queryKey: ['service-metrics', serviceId],
|
||||
queryFn: () => getServiceMetrics(serviceId),
|
||||
enabled: !isDemoMode && Boolean(serviceId),
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
// Accumulate live samples into a small history for the timeline charts.
|
||||
const [metricHistory, setMetricHistory] = useState<{ cpu: number[]; memory: number[] }>({ cpu: [], memory: [] });
|
||||
useEffect(() => {
|
||||
const sample = metricsQuery.data;
|
||||
if (!sample || sample.status !== 'ok') {
|
||||
return;
|
||||
}
|
||||
const memPercent = sample.memory_limit_bytes > 0
|
||||
? (sample.memory_usage_bytes / sample.memory_limit_bytes) * 100
|
||||
: 0;
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- accumulating polled samples is a legitimate sync-to-external pattern
|
||||
setMetricHistory((prev) => ({
|
||||
cpu: [...prev.cpu, Math.min(100, sample.cpu_percent)].slice(-24),
|
||||
memory: [...prev.memory, Math.min(100, memPercent)].slice(-24),
|
||||
}));
|
||||
}, [metricsQuery.data]);
|
||||
|
||||
const liveMetrics = isDemoMode ? null : metricsQuery.data ?? null;
|
||||
const hasLiveTelemetry = Boolean(liveMetrics && liveMetrics.status === 'ok' && liveMetrics.instances.length > 0);
|
||||
const runningInstances = liveMetrics?.instances.filter((instance) => instance.state === 'running').length ?? 0;
|
||||
const memoryPercent = liveMetrics && liveMetrics.memory_limit_bytes > 0
|
||||
? (liveMetrics.memory_usage_bytes / liveMetrics.memory_limit_bytes) * 100
|
||||
: 0;
|
||||
|
||||
// Demo mode renders seeded values so the layout can be previewed without a backend.
|
||||
const metricSet = useMemo(() => {
|
||||
if (!service) {
|
||||
if (!service || !isDemoMode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cpu = seededMetric(`${service.id}:cpu`, 12, 78);
|
||||
const memory = seededMetric(`${service.id}:mem`, 24, 91);
|
||||
const req = seededMetric(`${service.id}:req`, 120, 5100);
|
||||
const latency = seededMetric(`${service.id}:lat`, 17, 210);
|
||||
|
||||
return {
|
||||
cpu,
|
||||
memory,
|
||||
req,
|
||||
latency,
|
||||
cpu: seededMetric(`${service.id}:cpu`, 12, 78),
|
||||
memory: seededMetric(`${service.id}:mem`, 24, 91),
|
||||
req: seededMetric(`${service.id}:req`, 120, 5100),
|
||||
latency: seededMetric(`${service.id}:lat`, 17, 210),
|
||||
};
|
||||
}, [service]);
|
||||
}, [service, isDemoMode]);
|
||||
|
||||
const deploymentSummary = useMemo(() => {
|
||||
const deployments = isDemoMode ? [] : deploymentsQuery.data ?? [];
|
||||
@@ -372,30 +396,29 @@ export function ServiceDetailPage() {
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<MetricCard
|
||||
label="CPU"
|
||||
value={`${metricSet?.cpu ?? 0}%`}
|
||||
hint="Current utilization"
|
||||
value={isDemoMode ? `${metricSet?.cpu ?? 0}%` : hasLiveTelemetry ? `${liveMetrics!.cpu_percent.toFixed(1)}%` : '—'}
|
||||
hint={isDemoMode ? 'Current utilization' : hasLiveTelemetry ? `${runningInstances} running container${runningInstances === 1 ? '' : 's'}` : 'No running containers'}
|
||||
icon={Cpu}
|
||||
trend={metricSet && metricSet.cpu > 60 ? 'up' : metricSet && metricSet.cpu < 30 ? 'down' : 'stable'}
|
||||
trend={hasLiveTelemetry && liveMetrics!.cpu_percent > 60 ? 'up' : 'stable'}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Memory"
|
||||
value={`${metricSet?.memory ?? 0}%`}
|
||||
hint="Container footprint"
|
||||
value={isDemoMode ? `${metricSet?.memory ?? 0}%` : hasLiveTelemetry ? formatBytes(liveMetrics!.memory_usage_bytes) : '—'}
|
||||
hint={isDemoMode ? 'Container footprint' : hasLiveTelemetry ? `${memoryPercent.toFixed(0)}% of ${formatBytes(liveMetrics!.memory_limit_bytes)}` : 'No running containers'}
|
||||
icon={MemoryStick}
|
||||
trend={metricSet && metricSet.memory > 70 ? 'up' : 'stable'}
|
||||
trend={hasLiveTelemetry && memoryPercent > 70 ? 'up' : 'stable'}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Requests"
|
||||
value={`${metricSet?.req ?? 0}`}
|
||||
hint="Last 60 minutes"
|
||||
label="Network"
|
||||
value={isDemoMode ? `${metricSet?.req ?? 0}` : hasLiveTelemetry ? `${formatBytes(liveMetrics!.network_rx_bytes)}` : '—'}
|
||||
hint={isDemoMode ? 'Requests, last 60 minutes' : hasLiveTelemetry ? `RX total · TX ${formatBytes(liveMetrics!.network_tx_bytes)}` : 'No traffic recorded'}
|
||||
icon={Zap}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Latency"
|
||||
value={`${metricSet?.latency ?? 0}ms`}
|
||||
hint="P95 estimate"
|
||||
label="Instances"
|
||||
value={isDemoMode ? `${metricSet?.latency ?? 0}ms` : hasLiveTelemetry ? `${runningInstances}` : '0'}
|
||||
hint={isDemoMode ? 'P95 latency estimate' : hasLiveTelemetry ? `${liveMetrics!.instances.length} container${liveMetrics!.instances.length === 1 ? '' : 's'} discovered` : 'Deploy to create containers'}
|
||||
icon={Timer}
|
||||
trend={metricSet && metricSet.latency > 150 ? 'up' : 'stable'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -458,6 +481,20 @@ export function ServiceDetailPage() {
|
||||
{activeSection === 'metrics' && (
|
||||
<div className="space-y-6">
|
||||
{/* Metrics Grid with enhanced cards */}
|
||||
{!isDemoMode && !hasLiveTelemetry && (
|
||||
<div className="panel p-8 text-center">
|
||||
<div className="w-12 h-12 mx-auto mb-4 rounded-full bg-[var(--surface-muted)] flex items-center justify-center">
|
||||
<Activity size={22} className="text-[var(--text-tertiary)]" />
|
||||
</div>
|
||||
<p className="text-base font-medium text-[var(--text-primary)]">No telemetry yet</p>
|
||||
<p className="mt-2 text-sm text-[var(--text-secondary)]">
|
||||
{liveMetrics?.status === 'docker_unavailable'
|
||||
? 'Docker is not reachable on this host, so container metrics cannot be collected.'
|
||||
: 'Metrics appear once this service has running containers. Deploy it to start collecting.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{(isDemoMode || hasLiveTelemetry) && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="panel p-5 group hover:border-[var(--accent-primary)]/30 transition-all duration-300">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
@@ -467,28 +504,36 @@ export function ServiceDetailPage() {
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">CPU Timeline</p>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">Last 24 intervals</p>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">
|
||||
{isDemoMode ? 'Last 24 intervals' : 'Live samples · every 5s'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<span className="text-xs text-[var(--text-tertiary)]">Click to expand</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-32 items-end gap-1">
|
||||
{Array.from({ length: 24 }).map((_, i) => {
|
||||
const value = seededMetric(`${service.id}:cpu:${i}`, 8, 80);
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 rounded-[var(--radius-xs)] transition-all hover:opacity-80 cursor-pointer group/bar"
|
||||
style={{
|
||||
height: `${value}%`,
|
||||
background: '#ff7043',
|
||||
}}
|
||||
title={`${value}%`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{isDemoMode
|
||||
? Array.from({ length: 24 }).map((_, i) => {
|
||||
const value = seededMetric(`${service.id}:cpu:${i}`, 8, 80);
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 rounded-[var(--radius-xs)] transition-all hover:opacity-80 cursor-pointer"
|
||||
style={{ height: `${value}%`, background: '#ff7043' }}
|
||||
title={`${value}%`}
|
||||
/>
|
||||
);
|
||||
})
|
||||
: metricHistory.cpu.map((value, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 rounded-[var(--radius-xs)] transition-all hover:opacity-80"
|
||||
style={{ height: `${Math.max(3, value)}%`, background: '#ff7043' }}
|
||||
title={`${value.toFixed(1)}%`}
|
||||
/>
|
||||
))}
|
||||
{!isDemoMode && metricHistory.cpu.length === 0 && (
|
||||
<p className="self-center text-xs text-[var(--text-muted)]">Collecting first sample...</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -496,32 +541,77 @@ export function ServiceDetailPage() {
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="card-icon">
|
||||
<Zap size={18} />
|
||||
<MemoryStick size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">Request Timeline</p>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">Last 24 intervals</p>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">Memory Timeline</p>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">
|
||||
{isDemoMode ? 'Last 24 intervals' : 'Live samples · every 5s'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-32 items-end gap-1">
|
||||
{Array.from({ length: 24 }).map((_, i) => {
|
||||
const value = seededMetric(`${service.id}:req:${i}`, 18, 96);
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 rounded-[var(--radius-xs)] transition-all hover:opacity-80 cursor-pointer"
|
||||
style={{
|
||||
height: `${value}%`,
|
||||
background: '#3dd68c',
|
||||
}}
|
||||
title={`${value}%`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{isDemoMode
|
||||
? Array.from({ length: 24 }).map((_, i) => {
|
||||
const value = seededMetric(`${service.id}:req:${i}`, 18, 96);
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 rounded-[var(--radius-xs)] transition-all hover:opacity-80 cursor-pointer"
|
||||
style={{ height: `${value}%`, background: '#3dd68c' }}
|
||||
title={`${value}%`}
|
||||
/>
|
||||
);
|
||||
})
|
||||
: metricHistory.memory.map((value, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 rounded-[var(--radius-xs)] transition-all hover:opacity-80"
|
||||
style={{ height: `${Math.max(3, value)}%`, background: '#3dd68c' }}
|
||||
title={`${value.toFixed(1)}%`}
|
||||
/>
|
||||
))}
|
||||
{!isDemoMode && metricHistory.memory.length === 0 && (
|
||||
<p className="self-center text-xs text-[var(--text-muted)]">Collecting first sample...</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Instance list — real container states */}
|
||||
{!isDemoMode && hasLiveTelemetry && (
|
||||
<div className="panel p-5">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="card-icon">
|
||||
<Layers size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">Instances</p>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">Containers backing this service</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{liveMetrics!.instances.map((instance) => (
|
||||
<div
|
||||
key={instance.container_id}
|
||||
className="flex items-center justify-between p-3 rounded-lg bg-[var(--surface-muted)] border border-[var(--border-subtle)]"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className={`w-2 h-2 rounded-full flex-shrink-0 ${instance.state === 'running' ? 'bg-[var(--success)]' : 'bg-[var(--text-muted)]'}`} />
|
||||
<span className="mono text-xs text-[var(--text-primary)] truncate">{instance.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-[var(--text-tertiary)] flex-shrink-0">
|
||||
<span className="mono">{instance.cpu_percent.toFixed(1)}% CPU</span>
|
||||
<span className="mono">{formatBytes(instance.memory_usage_bytes)}</span>
|
||||
<span>{instance.started_at ? `up ${formatRelative(instance.started_at).replace(' ago', '')}` : instance.state}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Domain & Networking Panel */}
|
||||
{service.status === 'running' && (
|
||||
|
||||
@@ -1,25 +1,21 @@
|
||||
import { useMemo, useState, useEffect } from 'react';
|
||||
import { useParams, useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Cpu,
|
||||
MemoryStick,
|
||||
Zap,
|
||||
Users,
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Cpu,
|
||||
MemoryStick,
|
||||
Zap,
|
||||
Users,
|
||||
Globe,
|
||||
ArrowLeft,
|
||||
Search,
|
||||
Bell,
|
||||
ChevronUp,
|
||||
Activity,
|
||||
FileText,
|
||||
Sliders,
|
||||
Settings as SettingsIcon
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
LineChart,
|
||||
LineAreaChart,
|
||||
DonutChart,
|
||||
MultiLineChart,
|
||||
import {
|
||||
LineChart,
|
||||
LineAreaChart,
|
||||
DonutChart,
|
||||
EnhancedMetricCard,
|
||||
CacheMetricCard,
|
||||
PerformanceMetricCard
|
||||
@@ -49,7 +45,6 @@ const timePeriods = ['Day', 'Month', 'Year'];
|
||||
|
||||
export function ServiceMetricsDashboard() {
|
||||
const { serviceId = 'demo-service' } = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<TabKey>('metrics');
|
||||
@@ -86,10 +81,10 @@ export function ServiceMetricsDashboard() {
|
||||
{ data: perfData1, color: '#6c8ef0', fillOpacity: 0.15 },
|
||||
{ data: perfData2, color: '#9c7ef0', fillOpacity: 0.15 }
|
||||
],
|
||||
upSpeed: (Math.random() * 5 + 8).toFixed(1),
|
||||
downSpeed: (Math.random() * 3 + 4).toFixed(1),
|
||||
upSpeed: seededRandom(`${serviceId}:upspeed`, 8, 13).toFixed(1),
|
||||
downSpeed: seededRandom(`${serviceId}:downspeed`, 4, 7).toFixed(1),
|
||||
};
|
||||
}, [serviceId, timePeriod]);
|
||||
}, [serviceId]);
|
||||
|
||||
// Auto-update metrics every 2 seconds
|
||||
useEffect(() => {
|
||||
|
||||
@@ -14,11 +14,12 @@ import '@xyflow/react/dist/style.css';
|
||||
import type { ServiceEntity } from '@/lib/api-client';
|
||||
import { inferAutoConnections, type ServiceVariable } from '../auto-connections';
|
||||
import {
|
||||
DEFAULT_VIEWPORT,
|
||||
type CanvasGroup,
|
||||
type CanvasNodeLayout,
|
||||
type ProjectCanvasMetadata,
|
||||
} from '../model';
|
||||
import { loadCanvasMetadata, saveCanvasMetadata } from '../storage';
|
||||
import { canvasStorageKey, loadCanvasMetadata, saveCanvasMetadata } from '../storage';
|
||||
import { GroupNode, ServiceNode, type GroupNodeData, type ServiceNodeData } from './nodes';
|
||||
import { Plus, Layers, Maximize2, RotateCcw, Box, Link2 } from 'lucide-react';
|
||||
|
||||
@@ -152,6 +153,7 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
|
||||
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||
const persistTimeout = useRef<number | null>(null);
|
||||
const hydratedRef = useRef(false);
|
||||
const viewportRestoredRef = useRef(false);
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState<CanvasNode>([]);
|
||||
const [selectedServiceId, setSelectedServiceId] = useState<string | null>(null);
|
||||
const [viewportTick, setViewportTick] = useState(0);
|
||||
@@ -169,20 +171,34 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
|
||||
const edges = useMemo(() => toFlowEdges(inferredLinks), [inferredLinks]);
|
||||
|
||||
useEffect(() => {
|
||||
const hasStoredViewport = localStorage.getItem(canvasStorageKey(projectId)) !== null;
|
||||
const metadata = loadCanvasMetadata(projectId, services);
|
||||
const nextNodes = toFlowNodes(metadata, services, onOpenService);
|
||||
|
||||
viewportRestoredRef.current = false;
|
||||
setNodes(nextNodes);
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
setViewport(metadata.viewport, { duration: 120 });
|
||||
window.requestAnimationFrame(() => {
|
||||
const isDefaultViewport =
|
||||
metadata.viewport.x === DEFAULT_VIEWPORT.x &&
|
||||
metadata.viewport.y === DEFAULT_VIEWPORT.y &&
|
||||
metadata.viewport.zoom === DEFAULT_VIEWPORT.zoom;
|
||||
|
||||
if (!hasStoredViewport || isDefaultViewport) {
|
||||
void fitView({ padding: 0.15, duration: 120 });
|
||||
} else {
|
||||
void setViewport(metadata.viewport, { duration: 120 });
|
||||
}
|
||||
viewportRestoredRef.current = true;
|
||||
});
|
||||
});
|
||||
|
||||
hydratedRef.current = true;
|
||||
}, [projectId, serviceFingerprint, onOpenService, setNodes, setViewport, services]);
|
||||
}, [projectId, serviceFingerprint, onOpenService, setNodes, setViewport, fitView, services]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydratedRef.current) {
|
||||
if (!hydratedRef.current || !viewportRestoredRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -414,7 +430,6 @@ function CanvasInner({ projectId, services, variablesByService, onAddService, on
|
||||
}}
|
||||
onNodeDragStop={onNodeDragStop}
|
||||
onMoveEnd={() => setViewportTick((value) => value + 1)}
|
||||
fitView
|
||||
panOnDrag
|
||||
zoomOnScroll
|
||||
minZoom={0.25}
|
||||
|
||||
@@ -4,6 +4,9 @@ import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
createService,
|
||||
getProjectById,
|
||||
listGitBranches,
|
||||
listGitProviders,
|
||||
listGitRepositories,
|
||||
listServiceLogs,
|
||||
listServiceVariables,
|
||||
listServicesByProject,
|
||||
@@ -31,6 +34,8 @@ import {
|
||||
Sparkles,
|
||||
Box,
|
||||
Search,
|
||||
GitBranch,
|
||||
Container,
|
||||
} from 'lucide-react';
|
||||
|
||||
type WorkspaceView = 'canvas' | 'observability' | 'logs' | 'settings';
|
||||
@@ -59,6 +64,44 @@ function ServiceCreateDialog(props: {
|
||||
image: '',
|
||||
command: '',
|
||||
});
|
||||
const [source, setSource] = useState<'image' | 'git'>('image');
|
||||
const [providerId, setProviderId] = useState('');
|
||||
const [repoFullName, setRepoFullName] = useState('');
|
||||
const [repoSearch, setRepoSearch] = useState('');
|
||||
|
||||
const providersQuery = useQuery({
|
||||
queryKey: ['git-providers'],
|
||||
queryFn: listGitProviders,
|
||||
enabled: props.open && source === 'git',
|
||||
});
|
||||
|
||||
const repositoriesQuery = useQuery({
|
||||
queryKey: ['git-repositories', providerId, repoSearch],
|
||||
queryFn: () => listGitRepositories(providerId, repoSearch),
|
||||
enabled: props.open && source === 'git' && Boolean(providerId),
|
||||
});
|
||||
|
||||
const selectedRepo = useMemo(
|
||||
() => (repositoriesQuery.data ?? []).find((repo) => repo.full_name === repoFullName) ?? null,
|
||||
[repositoriesQuery.data, repoFullName],
|
||||
);
|
||||
|
||||
const branchesQuery = useQuery({
|
||||
queryKey: ['git-branches', providerId, repoFullName],
|
||||
queryFn: () => {
|
||||
const [owner, repo] = repoFullName.split('/');
|
||||
return listGitBranches(providerId, owner, repo);
|
||||
},
|
||||
enabled: props.open && source === 'git' && Boolean(providerId) && Boolean(repoFullName),
|
||||
});
|
||||
|
||||
const providers = providersQuery.data ?? [];
|
||||
const repositories = repositoriesQuery.data ?? [];
|
||||
const branches = branchesQuery.data ?? [];
|
||||
|
||||
const canSubmit =
|
||||
Boolean(form.name.trim()) &&
|
||||
(source === 'image' || Boolean(repoFullName));
|
||||
|
||||
if (!props.open) return null;
|
||||
|
||||
@@ -122,16 +165,169 @@ function ServiceCreateDialog(props: {
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
|
||||
Image <span className="normal-case text-[var(--text-muted)]">(optional)</span>
|
||||
Source
|
||||
</label>
|
||||
<input
|
||||
value={form.image ?? ''}
|
||||
onChange={(e) => setForm((p) => ({ ...p, image: e.target.value }))}
|
||||
className="w-full h-11 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all mono text-sm"
|
||||
placeholder="ghcr.io/org/app:latest"
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSource('image')}
|
||||
className={`flex items-center justify-center gap-2 h-10 rounded-[var(--radius-md)] border text-sm font-medium transition-colors ${
|
||||
source === 'image'
|
||||
? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] text-[var(--text-primary)]'
|
||||
: 'border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-tertiary)] hover:border-[var(--border-default)]'
|
||||
}`}
|
||||
>
|
||||
<Container size={14} />
|
||||
Image
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSource('git')}
|
||||
className={`flex items-center justify-center gap-2 h-10 rounded-[var(--radius-md)] border text-sm font-medium transition-colors ${
|
||||
source === 'git'
|
||||
? 'border-[var(--accent-primary)] bg-[var(--accent-primary-soft)] text-[var(--text-primary)]'
|
||||
: 'border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-tertiary)] hover:border-[var(--border-default)]'
|
||||
}`}
|
||||
>
|
||||
<GitBranch size={14} />
|
||||
Git Repository
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{source === 'image' ? (
|
||||
<div>
|
||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
|
||||
Image <span className="normal-case text-[var(--text-muted)]">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
value={form.image ?? ''}
|
||||
onChange={(e) => setForm((p) => ({ ...p, image: e.target.value }))}
|
||||
className="w-full h-11 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all mono text-sm"
|
||||
placeholder="ghcr.io/org/app:latest"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 p-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)]/50">
|
||||
<div>
|
||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
|
||||
Provider
|
||||
</label>
|
||||
<select
|
||||
value={providerId}
|
||||
onChange={(e) => {
|
||||
setProviderId(e.target.value);
|
||||
setRepoFullName('');
|
||||
setForm((p) => ({ ...p, git_repo: '', git_branch: '' }));
|
||||
}}
|
||||
className="w-full h-11 px-4 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-[var(--text-primary)] focus:border-[var(--accent-primary)] focus:ring-1 focus:ring-[var(--accent-primary)] transition-all"
|
||||
>
|
||||
<option value="">Select provider</option>
|
||||
{providers.map((provider) => (
|
||||
<option key={provider.id} value={provider.id}>{provider.display_name}</option>
|
||||
))}
|
||||
</select>
|
||||
{!providersQuery.isLoading && providers.length === 0 && (
|
||||
<p className="text-xs text-[var(--warn)] mt-1.5">
|
||||
No providers connected — add one in Settings → Git Providers
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{providerId && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
|
||||
Repository
|
||||
</label>
|
||||
<input
|
||||
value={repoSearch}
|
||||
onChange={(e) => setRepoSearch(e.target.value)}
|
||||
className="w-full h-9 px-3 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm mono mb-2"
|
||||
placeholder="Search repositories..."
|
||||
/>
|
||||
{repositoriesQuery.isLoading ? (
|
||||
<div className="py-3 text-center">
|
||||
<Loader2 size={14} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
|
||||
</div>
|
||||
) : repositoriesQuery.isError ? (
|
||||
<p className="text-xs text-[var(--error)] py-2">Failed to load repositories — check the provider token</p>
|
||||
) : repositories.length === 0 ? (
|
||||
<p className="text-xs text-[var(--text-muted)] py-2">No repositories found</p>
|
||||
) : (
|
||||
<div className="max-h-40 overflow-y-auto space-y-0.5 rounded-[var(--radius-sm)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] p-1">
|
||||
{repositories.slice(0, 50).map((repo) => (
|
||||
<button
|
||||
key={repo.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setRepoFullName(repo.full_name);
|
||||
setForm((p) => ({
|
||||
...p,
|
||||
git_repo: repo.clone_url ?? repo.full_name,
|
||||
git_branch: p.git_branch || repo.default_branch || '',
|
||||
}));
|
||||
}}
|
||||
className={`w-full flex items-center justify-between px-3 py-2 rounded-[var(--radius-sm)] text-left text-sm transition-colors ${
|
||||
repoFullName === repo.full_name
|
||||
? 'bg-[var(--accent-primary-soft)] text-[var(--text-primary)]'
|
||||
: 'text-[var(--text-secondary)] hover:bg-[var(--surface-hover)]'
|
||||
}`}
|
||||
>
|
||||
<span className="mono text-xs truncate">{repo.full_name}</span>
|
||||
<span className="text-[10px] text-[var(--text-muted)] ml-2 shrink-0">{repo.default_branch}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{repoFullName && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
|
||||
Branch
|
||||
</label>
|
||||
{branchesQuery.isLoading ? (
|
||||
<div className="py-2 text-center">
|
||||
<Loader2 size={14} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
|
||||
</div>
|
||||
) : branches.length > 0 ? (
|
||||
<select
|
||||
value={form.git_branch ?? ''}
|
||||
onChange={(e) => setForm((p) => ({ ...p, git_branch: e.target.value }))}
|
||||
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm mono"
|
||||
>
|
||||
<option value="">Select branch</option>
|
||||
{branches.map((branch) => (
|
||||
<option key={branch.name} value={branch.name}>{branch.name}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
value={form.git_branch ?? ''}
|
||||
onChange={(e) => setForm((p) => ({ ...p, git_branch: e.target.value }))}
|
||||
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm mono"
|
||||
placeholder={selectedRepo?.default_branch ?? 'main'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
|
||||
Build Path <span className="normal-case text-[var(--text-muted)]">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
value={form.build_path ?? ''}
|
||||
onChange={(e) => setForm((p) => ({ ...p, build_path: e.target.value }))}
|
||||
className="w-full h-10 px-3 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] text-sm mono"
|
||||
placeholder="/ (repo root)"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium uppercase tracking-wider text-[var(--text-tertiary)] mb-2">
|
||||
Command <span className="normal-case text-[var(--text-muted)]">(optional)</span>
|
||||
@@ -159,8 +355,18 @@ function ServiceCreateDialog(props: {
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
disabled={!form.name.trim() || props.loading}
|
||||
onClick={() => props.onSubmit({ ...form, name: form.name.trim(), image: form.image?.trim(), command: form.command?.trim() })}
|
||||
disabled={!canSubmit || props.loading}
|
||||
onClick={() =>
|
||||
props.onSubmit({
|
||||
...form,
|
||||
name: form.name.trim(),
|
||||
image: source === 'image' ? form.image?.trim() : '',
|
||||
command: form.command?.trim(),
|
||||
git_repo: source === 'git' ? form.git_repo?.trim() : '',
|
||||
git_branch: source === 'git' ? form.git_branch?.trim() : '',
|
||||
build_path: source === 'git' ? form.build_path?.trim() : '',
|
||||
})
|
||||
}
|
||||
className="px-5 py-2 rounded-[var(--radius-md)] text-white text-sm font-medium shadow-lg disabled:opacity-50 disabled:cursor-not-allowed transition-all"
|
||||
style={{ background: '#e8316a' }}
|
||||
>
|
||||
|
||||
+77
-351
@@ -84,6 +84,76 @@
|
||||
--bg-0: var(--bg-void);
|
||||
--bg-1: var(--bg-base);
|
||||
--bg-2: var(--bg-surface);
|
||||
--bg-primary: var(--bg-base);
|
||||
}
|
||||
|
||||
:root[data-theme='light'] {
|
||||
--bg-void: #f4f6fb;
|
||||
--bg-base: #ffffff;
|
||||
--bg-surface: #eef1f7;
|
||||
--bg-elevated: #ffffff;
|
||||
--bg-overlay: #ffffff;
|
||||
|
||||
--surface-card: #ffffff;
|
||||
--surface-card-hover: #eef1f7;
|
||||
--surface-muted: rgba(17, 18, 23, 0.05);
|
||||
--surface-glass: rgba(255, 255, 255, 0.72);
|
||||
--surface-base: #ffffff;
|
||||
|
||||
--text-primary: #151821;
|
||||
--text-secondary: #4f5668;
|
||||
--text-tertiary: #7b8498;
|
||||
--text-muted: #7b8498;
|
||||
|
||||
--accent-primary: #d92d64;
|
||||
--accent-primary-soft: rgba(217, 45, 100, 0.12);
|
||||
--accent-primary-glow: rgba(217, 45, 100, 0.22);
|
||||
--accent-secondary: #7357d8;
|
||||
--accent-tertiary: #2f67d8;
|
||||
|
||||
--success: #168a55;
|
||||
--success-soft: rgba(22, 138, 85, 0.12);
|
||||
--success-glow: rgba(22, 138, 85, 0.2);
|
||||
|
||||
--warning: #c7511f;
|
||||
--warning-soft: rgba(199, 81, 31, 0.12);
|
||||
--warning-glow: rgba(199, 81, 31, 0.2);
|
||||
|
||||
--error: #c9362b;
|
||||
--error-soft: rgba(201, 54, 43, 0.12);
|
||||
--error-glow: rgba(201, 54, 43, 0.2);
|
||||
|
||||
--info: #2f67d8;
|
||||
--info-soft: rgba(47, 103, 216, 0.12);
|
||||
|
||||
--border-subtle: rgba(21, 24, 33, 0.1);
|
||||
--border-default: rgba(21, 24, 33, 0.16);
|
||||
--border-strong: rgba(217, 45, 100, 0.35);
|
||||
--border-glow: rgba(217, 45, 100, 0.45);
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(17, 18, 23, 0.08);
|
||||
--shadow-md: 0 4px 12px rgba(17, 18, 23, 0.1);
|
||||
--shadow-lg: 0 8px 30px rgba(17, 18, 23, 0.12);
|
||||
--shadow-xl: 0 16px 48px rgba(17, 18, 23, 0.16);
|
||||
--shadow-glow: 0 0 20px var(--accent-primary-glow);
|
||||
--shadow-glow-lg: 0 0 40px var(--accent-primary-glow);
|
||||
|
||||
--text: var(--text-primary);
|
||||
--text-dim: var(--text-secondary);
|
||||
--line: var(--border-default);
|
||||
--line-strong: var(--border-strong);
|
||||
--accent: var(--accent-primary);
|
||||
--accent-soft: var(--accent-primary-soft);
|
||||
--ok: var(--success);
|
||||
--warn: var(--warning);
|
||||
--bad: var(--error);
|
||||
--surface: var(--bg-elevated);
|
||||
--surface-soft: var(--surface-card);
|
||||
--surface-muted: var(--bg-surface);
|
||||
--bg-0: var(--bg-void);
|
||||
--bg-1: var(--bg-base);
|
||||
--bg-2: var(--bg-surface);
|
||||
--bg-primary: var(--bg-base);
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -100,7 +170,7 @@ body,
|
||||
body {
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
color: var(--text-primary);
|
||||
background: #16171c;
|
||||
background: var(--bg-void);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
@@ -115,7 +185,7 @@ body {
|
||||
.app-shell {
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
background: #16171c;
|
||||
background: var(--bg-void);
|
||||
}
|
||||
|
||||
/* Ambient glow effects for premium depth */
|
||||
@@ -162,56 +232,6 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideOutRight {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Animations from self.html */
|
||||
@keyframes fadeUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Live pulse animation for status indicators */
|
||||
@keyframes livePulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
transform: scale(1.15);
|
||||
}
|
||||
}
|
||||
|
||||
.live-pulse {
|
||||
animation: livePulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Subtle glow pulse for active elements */
|
||||
@keyframes glowPulse {
|
||||
0%, 100% {
|
||||
@@ -226,22 +246,6 @@ body {
|
||||
animation: glowPulse 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Slide in from right - for toasts and modals */
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slide-in-right {
|
||||
animation: slideInRight 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Fade in scale - for modals and overlays */
|
||||
@keyframes fadeInScale {
|
||||
from {
|
||||
@@ -270,28 +274,6 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideOutRight {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
@@ -365,177 +347,6 @@ textarea {
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
/* Card icon - matching self.html */
|
||||
.card-icon {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card-icon svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
stroke: #9295a4;
|
||||
fill: none;
|
||||
stroke-width: 1.8;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
/* Arrow button - matching self.html */
|
||||
.arrow-btn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 9px;
|
||||
background: #e8316a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: box-shadow 0.15s, transform 0.15s;
|
||||
}
|
||||
|
||||
.arrow-btn:hover {
|
||||
transform: scale(1.07);
|
||||
}
|
||||
|
||||
.arrow-btn svg {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
stroke: white;
|
||||
fill: none;
|
||||
stroke-width: 2.5;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
/* Pill group - matching self.html */
|
||||
.pill-group {
|
||||
display: flex;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pill {
|
||||
padding: 5px 14px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
color: #6b6e7d;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.pill.active {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #e8e9f0;
|
||||
}
|
||||
|
||||
.pill:hover:not(.active) {
|
||||
color: #9295a4;
|
||||
}
|
||||
|
||||
/* Flag - matching self.html */
|
||||
.flag {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 15px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
/* Stat dot - matching self.html */
|
||||
.stat-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Cache segment - matching self.html */
|
||||
.cache-seg {
|
||||
height: 32px;
|
||||
transition: opacity 0.2s, filter 0.2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cache-seg:hover {
|
||||
filter: brightness(1.15);
|
||||
}
|
||||
|
||||
/* Speed row - matching self.html */
|
||||
.speed-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.speed-row svg {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
stroke-width: 2.5;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
/* Live indicator - matching self.html */
|
||||
.live-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #3dd68c;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
margin-right: 6px;
|
||||
animation: pulse-dot 2s infinite;
|
||||
}
|
||||
|
||||
.live-dot.stopped {
|
||||
background: #6b6e7d;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* Badge - matching self.html */
|
||||
.badge-active {
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
background: rgba(61, 214, 140, 0.15);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.7px;
|
||||
color: #3dd68c;
|
||||
text-transform: uppercase;
|
||||
transition: all 0.3s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.badge-stopped {
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.7px;
|
||||
color: #6b6e7d;
|
||||
text-transform: uppercase;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Subtle grid background - Railway-style dots */
|
||||
.subtle-grid {
|
||||
background-image:
|
||||
@@ -589,18 +400,7 @@ textarea {
|
||||
100% { transform: scale(2); opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes pulse-dot {
|
||||
0% { box-shadow: 0 0 0 0 rgba(61, 214, 140, 0.7); }
|
||||
70% { box-shadow: 0 0 0 6px rgba(61, 214, 140, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(61, 214, 140, 0); }
|
||||
}
|
||||
|
||||
/* Card animations - matching self.html */
|
||||
@keyframes fadeUp {
|
||||
from { opacity: 0; transform: translateY(12px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.animate-fade-up {
|
||||
animation: fadeUp 0.4s ease both;
|
||||
}
|
||||
@@ -718,17 +518,6 @@ textarea {
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
|
||||
.shimmer {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 255, 255, 0.03) 25%,
|
||||
rgba(255, 255, 255, 0.08) 50%,
|
||||
rgba(255, 255, 255, 0.03) 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
/* Hover lift effect for cards */
|
||||
.hover-lift {
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
@@ -778,18 +567,6 @@ textarea {
|
||||
animation: gradient-rotate 3s ease infinite;
|
||||
}
|
||||
|
||||
/* Ripple effect for buttons */
|
||||
@keyframes ripple {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
opacity: 0.5;
|
||||
}
|
||||
100% {
|
||||
transform: scale(4);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.ripple-container {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
@@ -1073,8 +850,8 @@ textarea {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: var(--surface-muted);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 10px;
|
||||
padding: 0 12px;
|
||||
height: 34px;
|
||||
@@ -1084,7 +861,7 @@ textarea {
|
||||
.search-box svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
stroke: #6b6e7d;
|
||||
stroke: var(--text-tertiary);
|
||||
fill: none;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
@@ -1095,14 +872,14 @@ textarea {
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: #9295a4;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
width: 100%;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.search-box input::placeholder {
|
||||
color: #6b6e7d;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* Tab - self.html exact match */
|
||||
@@ -1172,47 +949,6 @@ textarea {
|
||||
color: #9295a4;
|
||||
}
|
||||
|
||||
/* Arrow Button - self.html style (pink circular) */
|
||||
.arrow-btn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 9px;
|
||||
background: var(--accent-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: box-shadow 0.15s, transform 0.15s;
|
||||
}
|
||||
|
||||
.arrow-btn:hover {
|
||||
transform: scale(1.07);
|
||||
}
|
||||
|
||||
.arrow-btn svg {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
stroke: white;
|
||||
fill: none;
|
||||
stroke-width: 2.5;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
/* Live Dot Pulse - self.html style */
|
||||
@keyframes pulse-dot {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(61, 214, 140, 0.7);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 6px rgba(61, 214, 140, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(61, 214, 140, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.live-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
@@ -1489,16 +1225,6 @@ textarea {
|
||||
animation: ripple 0.4s ease-out;
|
||||
}
|
||||
|
||||
/* Shimmer loading effect */
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.shimmer {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
|
||||
@@ -148,6 +148,105 @@ export type UpdateUserProfileInput = {
|
||||
avatarUrl?: string;
|
||||
};
|
||||
|
||||
export type CreateUserInput = {
|
||||
name: string;
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
export type UpgradeStatus = {
|
||||
imageRef: string;
|
||||
registry: string;
|
||||
dockerAvailable: boolean;
|
||||
installed: boolean;
|
||||
digest?: string;
|
||||
size?: number;
|
||||
authConfigured: boolean;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type SystemLoad = {
|
||||
load1m: number;
|
||||
load5m: number;
|
||||
load15m: number;
|
||||
};
|
||||
|
||||
export type HostMonitoring = {
|
||||
hostname: string;
|
||||
os: string;
|
||||
architecture: string;
|
||||
cpu: {
|
||||
cores: number;
|
||||
};
|
||||
memory: {
|
||||
total: number;
|
||||
used: number;
|
||||
available: number;
|
||||
usagePercent: number;
|
||||
};
|
||||
storage: {
|
||||
path: string;
|
||||
total: number;
|
||||
used: number;
|
||||
available: number;
|
||||
usagePercent: number;
|
||||
};
|
||||
load: SystemLoad;
|
||||
uptimeSeconds: number;
|
||||
dockerAvailable: boolean;
|
||||
docker?: {
|
||||
containers?: number;
|
||||
images?: number;
|
||||
driver?: string;
|
||||
server?: string;
|
||||
};
|
||||
collectedAt: string;
|
||||
};
|
||||
|
||||
export type AgentCapabilities = {
|
||||
containerRuntimes: string[];
|
||||
supportedArchitectures: string[];
|
||||
maxContainers: number;
|
||||
storageDriver: string;
|
||||
networkPlugins: string[];
|
||||
features: string[];
|
||||
};
|
||||
|
||||
export type NodeResources = {
|
||||
cpu: {
|
||||
cores: number;
|
||||
allocation: number;
|
||||
usage: number;
|
||||
};
|
||||
memory: {
|
||||
total: number;
|
||||
allocated: number;
|
||||
used: number;
|
||||
available: number;
|
||||
};
|
||||
storage: {
|
||||
total: number;
|
||||
allocated: number;
|
||||
used: number;
|
||||
available: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type NodeAgentEntity = {
|
||||
id: string;
|
||||
name: string;
|
||||
hostname: string;
|
||||
ipAddress: string;
|
||||
port: number;
|
||||
status: string;
|
||||
version: string;
|
||||
capabilities: AgentCapabilities;
|
||||
resources: NodeResources;
|
||||
lastHeartbeat?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export type AuditLogEntity = {
|
||||
id: string;
|
||||
userId: string;
|
||||
@@ -256,6 +355,91 @@ type RawServiceLog = components['schemas']['ServiceLogEntry'];
|
||||
type RawServiceLogsResponse = components['schemas']['ServiceLogsResponse'];
|
||||
type RawDeploymentLogsResponse = components['schemas']['DeploymentLogsResponse'];
|
||||
type RawRollbackDeploymentResponse = components['schemas']['RollbackDeploymentResponse'];
|
||||
type RawUpgradeStatus = {
|
||||
image_ref?: string;
|
||||
registry?: string;
|
||||
docker_available?: boolean;
|
||||
installed?: boolean;
|
||||
digest?: string;
|
||||
size?: number;
|
||||
auth_configured?: boolean;
|
||||
message?: string;
|
||||
};
|
||||
type RawSystemLoad = {
|
||||
load_1m?: number;
|
||||
load_5m?: number;
|
||||
load_15m?: number;
|
||||
};
|
||||
type RawHostMonitoring = {
|
||||
hostname?: string;
|
||||
os?: string;
|
||||
architecture?: string;
|
||||
cpu?: {
|
||||
cores?: number;
|
||||
};
|
||||
memory?: {
|
||||
total?: number;
|
||||
used?: number;
|
||||
available?: number;
|
||||
usage_percent?: number;
|
||||
};
|
||||
storage?: {
|
||||
path?: string;
|
||||
total?: number;
|
||||
used?: number;
|
||||
available?: number;
|
||||
usage_percent?: number;
|
||||
};
|
||||
load?: RawSystemLoad;
|
||||
uptime_seconds?: number;
|
||||
docker_available?: boolean;
|
||||
docker?: {
|
||||
containers?: number;
|
||||
images?: number;
|
||||
driver?: string;
|
||||
server?: string;
|
||||
};
|
||||
collected_at?: string;
|
||||
};
|
||||
type RawNodeAgent = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
hostname?: string;
|
||||
ip_address?: string;
|
||||
port?: number;
|
||||
status?: string;
|
||||
version?: string;
|
||||
capabilities?: {
|
||||
container_runtimes?: string[];
|
||||
supported_architectures?: string[];
|
||||
max_containers?: number;
|
||||
storage_driver?: string;
|
||||
network_plugins?: string[];
|
||||
features?: string[];
|
||||
};
|
||||
resources?: {
|
||||
cpu?: {
|
||||
cores?: number;
|
||||
allocation?: number;
|
||||
usage?: number;
|
||||
};
|
||||
memory?: {
|
||||
total?: number;
|
||||
allocated?: number;
|
||||
used?: number;
|
||||
available?: number;
|
||||
};
|
||||
storage?: {
|
||||
total?: number;
|
||||
allocated?: number;
|
||||
used?: number;
|
||||
available?: number;
|
||||
};
|
||||
};
|
||||
last_heartbeat?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
|
||||
export type CreateProjectInput = components['schemas']['CreateProjectRequest'];
|
||||
export type CreateServiceInput = components['schemas']['CreateServiceRequest'] & {
|
||||
@@ -286,12 +470,17 @@ type JsonLike = Record<string, unknown>;
|
||||
|
||||
const rawBase = (import.meta.env.VITE_API_URL as string | undefined) ?? 'http://localhost:8082';
|
||||
const normalizedBase = rawBase.replace(/\/$/, '');
|
||||
const API_ROOT = normalizedBase.replace(/\/api\/v1$/, '').replace(/\/api$/, '');
|
||||
const API_BASE = /\/api\/v1$/.test(normalizedBase) ? normalizedBase : `${normalizedBase}/api/v1`;
|
||||
|
||||
export function getApiBaseUrl(): string {
|
||||
return API_BASE;
|
||||
}
|
||||
|
||||
export function getAgentPublicBaseUrl(): string {
|
||||
return `${API_ROOT}/api/agents`;
|
||||
}
|
||||
|
||||
function authHeaders(): HeadersInit {
|
||||
return { 'Content-Type': 'application/json' };
|
||||
}
|
||||
@@ -669,6 +858,151 @@ export async function updateCurrentUserProfile(input: UpdateUserProfileInput): P
|
||||
return profile;
|
||||
}
|
||||
|
||||
export async function createUser(input: CreateUserInput): Promise<UserProfile> {
|
||||
const payload = await requestJson<RawUserProfile | { user?: RawUserProfile }>(`/users`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
|
||||
const rawProfile = (payload as { user?: RawUserProfile }).user ?? (payload as RawUserProfile);
|
||||
const profile = normalizeUserProfile(rawProfile);
|
||||
|
||||
if (!profile) {
|
||||
throw new ApiError('Create user response is invalid', 500);
|
||||
}
|
||||
|
||||
return profile;
|
||||
}
|
||||
|
||||
function normalizeUpgradeStatus(payload: RawUpgradeStatus): UpgradeStatus {
|
||||
return {
|
||||
imageRef: payload.image_ref ?? '',
|
||||
registry: payload.registry ?? '',
|
||||
dockerAvailable: Boolean(payload.docker_available),
|
||||
installed: Boolean(payload.installed),
|
||||
digest: payload.digest,
|
||||
size: payload.size,
|
||||
authConfigured: Boolean(payload.auth_configured),
|
||||
message: payload.message ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSystemLoad(payload?: RawSystemLoad): SystemLoad {
|
||||
return {
|
||||
load1m: payload?.load_1m ?? 0,
|
||||
load5m: payload?.load_5m ?? 0,
|
||||
load15m: payload?.load_15m ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeHostMonitoring(payload: RawHostMonitoring): HostMonitoring {
|
||||
return {
|
||||
hostname: payload.hostname ?? '',
|
||||
os: payload.os ?? '',
|
||||
architecture: payload.architecture ?? '',
|
||||
cpu: {
|
||||
cores: payload.cpu?.cores ?? 0,
|
||||
},
|
||||
memory: {
|
||||
total: payload.memory?.total ?? 0,
|
||||
used: payload.memory?.used ?? 0,
|
||||
available: payload.memory?.available ?? 0,
|
||||
usagePercent: payload.memory?.usage_percent ?? 0,
|
||||
},
|
||||
storage: {
|
||||
path: payload.storage?.path ?? '/',
|
||||
total: payload.storage?.total ?? 0,
|
||||
used: payload.storage?.used ?? 0,
|
||||
available: payload.storage?.available ?? 0,
|
||||
usagePercent: payload.storage?.usage_percent ?? 0,
|
||||
},
|
||||
load: normalizeSystemLoad(payload.load),
|
||||
uptimeSeconds: payload.uptime_seconds ?? 0,
|
||||
dockerAvailable: Boolean(payload.docker_available),
|
||||
docker: payload.docker,
|
||||
collectedAt: payload.collected_at ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAgent(agent: RawNodeAgent): NodeAgentEntity | null {
|
||||
if (!agent.id || !agent.name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: agent.id,
|
||||
name: agent.name,
|
||||
hostname: agent.hostname ?? '',
|
||||
ipAddress: agent.ip_address ?? '',
|
||||
port: agent.port ?? 0,
|
||||
status: agent.status ?? 'unknown',
|
||||
version: agent.version ?? '',
|
||||
capabilities: {
|
||||
containerRuntimes: agent.capabilities?.container_runtimes ?? [],
|
||||
supportedArchitectures: agent.capabilities?.supported_architectures ?? [],
|
||||
maxContainers: agent.capabilities?.max_containers ?? 0,
|
||||
storageDriver: agent.capabilities?.storage_driver ?? '',
|
||||
networkPlugins: agent.capabilities?.network_plugins ?? [],
|
||||
features: agent.capabilities?.features ?? [],
|
||||
},
|
||||
resources: {
|
||||
cpu: {
|
||||
cores: agent.resources?.cpu?.cores ?? 0,
|
||||
allocation: agent.resources?.cpu?.allocation ?? 0,
|
||||
usage: agent.resources?.cpu?.usage ?? 0,
|
||||
},
|
||||
memory: {
|
||||
total: agent.resources?.memory?.total ?? 0,
|
||||
allocated: agent.resources?.memory?.allocated ?? 0,
|
||||
used: agent.resources?.memory?.used ?? 0,
|
||||
available: agent.resources?.memory?.available ?? 0,
|
||||
},
|
||||
storage: {
|
||||
total: agent.resources?.storage?.total ?? 0,
|
||||
allocated: agent.resources?.storage?.allocated ?? 0,
|
||||
used: agent.resources?.storage?.used ?? 0,
|
||||
available: agent.resources?.storage?.available ?? 0,
|
||||
},
|
||||
},
|
||||
lastHeartbeat: agent.last_heartbeat,
|
||||
createdAt: agent.created_at,
|
||||
updatedAt: agent.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAgentArray(raw: unknown): NodeAgentEntity[] {
|
||||
if (!Array.isArray(raw)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return raw
|
||||
.map((entry) => normalizeAgent(entry as RawNodeAgent))
|
||||
.filter((entry): entry is NodeAgentEntity => entry !== null);
|
||||
}
|
||||
|
||||
export async function getUpgradeStatus(): Promise<UpgradeStatus> {
|
||||
const payload = await requestJson<RawUpgradeStatus>(`/system/upgrade/status`);
|
||||
return normalizeUpgradeStatus(payload);
|
||||
}
|
||||
|
||||
export async function pullUpgradeImage(): Promise<UpgradeStatus> {
|
||||
const payload = await requestJson<RawUpgradeStatus>(`/system/upgrade/pull`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
return normalizeUpgradeStatus(payload);
|
||||
}
|
||||
|
||||
export async function getHostMonitoring(): Promise<HostMonitoring> {
|
||||
const payload = await requestJson<RawHostMonitoring>(`/system/host`);
|
||||
return normalizeHostMonitoring(payload);
|
||||
}
|
||||
|
||||
export async function listAgents(): Promise<NodeAgentEntity[]> {
|
||||
const payload = await requestJson<{ agents?: RawNodeAgent[] }>(`/agents`);
|
||||
return normalizeAgentArray(payload.agents);
|
||||
}
|
||||
|
||||
export async function createProject(input: CreateProjectInput): Promise<ProjectEntity> {
|
||||
const payload = await requestJson<RawProject | { project?: RawProject }>(`/projects`, {
|
||||
method: 'POST',
|
||||
@@ -958,3 +1292,109 @@ export function serviceStatusClass(status: ServiceStatus): string {
|
||||
return 'status-stopped';
|
||||
}
|
||||
}
|
||||
|
||||
export type ServiceInstanceMetrics = {
|
||||
container_id: string;
|
||||
name: string;
|
||||
state: string;
|
||||
cpu_percent: number;
|
||||
memory_usage_bytes: number;
|
||||
memory_limit_bytes: number;
|
||||
network_rx_bytes: number;
|
||||
network_tx_bytes: number;
|
||||
started_at?: string;
|
||||
};
|
||||
|
||||
export type ServiceMetrics = {
|
||||
service_id: string;
|
||||
status: 'ok' | 'no_containers' | 'docker_unavailable';
|
||||
instances: ServiceInstanceMetrics[];
|
||||
cpu_percent: number;
|
||||
memory_usage_bytes: number;
|
||||
memory_limit_bytes: number;
|
||||
network_rx_bytes: number;
|
||||
network_tx_bytes: number;
|
||||
collected_at: string;
|
||||
};
|
||||
|
||||
export async function getServiceMetrics(serviceId: string): Promise<ServiceMetrics> {
|
||||
const payload = await requestJson<{ metrics?: ServiceMetrics }>(`/services/${serviceId}/metrics`);
|
||||
if (!payload.metrics) {
|
||||
throw new ApiError('Service metrics payload is invalid', 500);
|
||||
}
|
||||
return payload.metrics;
|
||||
}
|
||||
|
||||
export type GitProviderEntity = {
|
||||
id: string;
|
||||
name: string;
|
||||
display_name: string;
|
||||
api_url: string;
|
||||
created_at?: string;
|
||||
};
|
||||
|
||||
export type GitRepositoryEntity = {
|
||||
id: string;
|
||||
provider_id: string;
|
||||
name: string;
|
||||
full_name: string;
|
||||
description: string;
|
||||
clone_url: string;
|
||||
default_branch: string;
|
||||
is_private: boolean;
|
||||
};
|
||||
|
||||
export type GitBranchEntity = {
|
||||
name: string;
|
||||
protected: boolean;
|
||||
};
|
||||
|
||||
export async function listGitProviders(): Promise<GitProviderEntity[]> {
|
||||
const payload = await requestJson<{ providers?: GitProviderEntity[] }>('/git/providers');
|
||||
return payload.providers ?? [];
|
||||
}
|
||||
|
||||
export async function createGitProvider(input: {
|
||||
name: 'github' | 'gitlab' | 'bitbucket' | 'gitea';
|
||||
display_name: string;
|
||||
access_token: string;
|
||||
api_url?: string;
|
||||
}): Promise<GitProviderEntity> {
|
||||
const payload = await requestJson<GitProviderEntity>('/git/providers', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!payload.id) {
|
||||
throw new ApiError('Provider response is invalid', 500);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function deleteGitProvider(providerId: string): Promise<void> {
|
||||
await requestJson(`/git/providers/${providerId}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export async function listGitRepositories(
|
||||
providerId: string,
|
||||
search?: string,
|
||||
): Promise<GitRepositoryEntity[]> {
|
||||
const params = new URLSearchParams({ limit: '100' });
|
||||
if (search?.trim()) {
|
||||
params.set('search', search.trim());
|
||||
}
|
||||
const payload = await requestJson<{ repositories?: GitRepositoryEntity[] }>(
|
||||
`/git/providers/${providerId}/repositories?${params.toString()}`,
|
||||
);
|
||||
return payload.repositories ?? [];
|
||||
}
|
||||
|
||||
export async function listGitBranches(
|
||||
providerId: string,
|
||||
owner: string,
|
||||
repo: string,
|
||||
): Promise<GitBranchEntity[]> {
|
||||
const payload = await requestJson<{ branches?: GitBranchEntity[] }>(
|
||||
`/git/providers/${providerId}/repositories/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/branches`,
|
||||
);
|
||||
return payload.branches ?? [];
|
||||
}
|
||||
@@ -16,6 +16,12 @@ export type AuthSessionPayload = {
|
||||
session: AuthSession;
|
||||
};
|
||||
|
||||
export type AuthBootstrap = {
|
||||
hasUsers: boolean;
|
||||
userCount: number;
|
||||
mode: 'register' | 'login';
|
||||
};
|
||||
|
||||
export class AuthError extends Error {
|
||||
readonly status: number;
|
||||
|
||||
@@ -33,6 +39,30 @@ export function getAuthBaseUrl(): string {
|
||||
return AUTH_BASE;
|
||||
}
|
||||
|
||||
export async function getAuthBootstrap(): Promise<AuthBootstrap> {
|
||||
const apiBase = ((import.meta.env.VITE_API_URL as string | undefined) ?? 'http://localhost:8082').replace(/\/$/, '');
|
||||
const normalizedApiBase = /\/api\/v1$/.test(apiBase) ? apiBase : `${apiBase}/api/v1`;
|
||||
const response = await fetch(`${normalizedApiBase}/auth/bootstrap`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
});
|
||||
const payload = (await response.json().catch(() => null)) as Record<string, unknown> | null;
|
||||
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
(payload?.message as string | undefined) ??
|
||||
(payload?.error as string | undefined) ??
|
||||
`Auth bootstrap request failed with status ${response.status}`;
|
||||
throw new AuthError(message, response.status);
|
||||
}
|
||||
|
||||
return {
|
||||
hasUsers: Boolean(payload?.has_users),
|
||||
userCount: typeof payload?.user_count === 'number' ? payload.user_count : 0,
|
||||
mode: payload?.mode === 'register' ? 'register' : 'login',
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSessionPayload(payload: unknown): AuthSessionPayload | null {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return null;
|
||||
@@ -160,19 +190,6 @@ export async function signUpWithEmail(name: string, email: string, password: str
|
||||
return normalizeSessionPayload(payload);
|
||||
}
|
||||
|
||||
export async function requestMagicLinkInvite(email: string, callbackURL: string): Promise<void> {
|
||||
await authRequest('/sign-in/magic-link', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
callbackURL,
|
||||
metadata: {
|
||||
invite: true,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function startGitHubSignIn(callbackURL: string): Promise<void> {
|
||||
const payload = await authRequest('/sign-in/social', {
|
||||
method: 'POST',
|
||||
@@ -189,11 +206,11 @@ export async function startGitHubSignIn(callbackURL: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function startOAuth2ProviderSignIn(providerId: string, callbackURL: string): Promise<void> {
|
||||
const payload = await authRequest('/sign-in/oauth2', {
|
||||
export async function startGoogleSignIn(callbackURL: string): Promise<void> {
|
||||
const payload = await authRequest('/sign-in/social', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
providerId,
|
||||
provider: 'google',
|
||||
callbackURL,
|
||||
disableRedirect: true,
|
||||
}),
|
||||
@@ -205,18 +222,6 @@ async function startOAuth2ProviderSignIn(providerId: string, callbackURL: string
|
||||
}
|
||||
}
|
||||
|
||||
export async function startGitLabSignIn(callbackURL: string): Promise<void> {
|
||||
await startOAuth2ProviderSignIn('gitlab', callbackURL);
|
||||
}
|
||||
|
||||
export async function startBitbucketSignIn(callbackURL: string): Promise<void> {
|
||||
await startOAuth2ProviderSignIn('bitbucket', callbackURL);
|
||||
}
|
||||
|
||||
export async function startGiteaSignIn(callbackURL: string): Promise<void> {
|
||||
await startOAuth2ProviderSignIn('gitea', callbackURL);
|
||||
}
|
||||
|
||||
export async function signOutAuthSession(): Promise<void> {
|
||||
await authRequest('/sign-out', {
|
||||
method: 'POST',
|
||||
|
||||
@@ -36,6 +36,16 @@ export function formatDate(iso?: string): string {
|
||||
return dt.toLocaleString();
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) {
|
||||
return '0 B';
|
||||
}
|
||||
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
|
||||
const index = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024)));
|
||||
const value = bytes / Math.pow(1024, index);
|
||||
return `${value >= 100 ? Math.round(value) : value.toFixed(1)} ${units[index]}`;
|
||||
}
|
||||
|
||||
export function seededMetric(seed: string, min: number, max: number): number {
|
||||
let hash = 0;
|
||||
for (let idx = 0; idx < seed.length; idx += 1) {
|
||||
|
||||
@@ -17,10 +17,12 @@ function toWebSocketUrl(apiBase: string): string {
|
||||
return `${wsBase}/ws`;
|
||||
}
|
||||
|
||||
export type LiveSyncStatus = 'idle' | 'offline' | 'live';
|
||||
|
||||
export function useBuildUpdates(
|
||||
buildIds: string[],
|
||||
onBuildUpdate: (payload: BuildUpdatePayload) => void,
|
||||
): boolean {
|
||||
): LiveSyncStatus {
|
||||
const [connected, setConnected] = useState(false);
|
||||
const callbackRef = useRef(onBuildUpdate);
|
||||
useEffect(() => {
|
||||
@@ -127,5 +129,8 @@ export function useBuildUpdates(
|
||||
};
|
||||
}, [buildIdsKey, subscriptionIds]);
|
||||
|
||||
return subscriptionIds.length > 0 && connected;
|
||||
if (subscriptionIds.length === 0) {
|
||||
return 'idle';
|
||||
}
|
||||
return connected ? 'live' : 'offline';
|
||||
}
|
||||
@@ -184,7 +184,6 @@ export function CacheMetricCard({
|
||||
}: CacheMetricCardProps) {
|
||||
const cachePercent = Math.round((cacheMB / totalMB) * 100);
|
||||
const nonCachePercent = Math.round((nonCacheMB / totalMB) * 100);
|
||||
const freePercent = 100 - cachePercent - nonCachePercent;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -316,7 +315,6 @@ export function PerformanceMetricCard({
|
||||
percentage,
|
||||
upSpeed,
|
||||
downSpeed,
|
||||
datasets,
|
||||
onClick,
|
||||
animationDelay = 0,
|
||||
}: PerformanceMetricCardProps) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, ErrorInfo, ReactNode } from 'react';
|
||||
import { Component } from 'react';
|
||||
import type { ErrorInfo, ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as ToastPrimitive from '@radix-ui/react-toast';
|
||||
import { createContext, useContext, useState, ReactNode } from 'react';
|
||||
import { useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { IconX, IconCheck, IconAlertCircle, IconInfoCircle } from '@tabler/icons-react';
|
||||
|
||||
type ToastType = 'success' | 'error' | 'info' | 'warning';
|
||||
import { ToastContext, type ToastType } from '../hooks/use-toast';
|
||||
|
||||
interface Toast {
|
||||
id: string;
|
||||
@@ -11,20 +11,6 @@ interface Toast {
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface ToastContextValue {
|
||||
showToast: (type: ToastType, title: string, description?: string) => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | undefined>(undefined);
|
||||
|
||||
export function useToast() {
|
||||
const context = useContext(ToastContext);
|
||||
if (!context) {
|
||||
throw new Error('useToast must be used within ToastProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
interface ToastProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
@@ -32,7 +18,11 @@ interface ToastProviderProps {
|
||||
export function ToastProvider({ children }: ToastProviderProps) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
const showToast = (type: ToastType, title: string, description?: string) => {
|
||||
const showToast = (first: ToastType | string, second: ToastType | string, description?: string) => {
|
||||
const toastTypes: ToastType[] = ['success', 'error', 'info', 'warning'];
|
||||
const firstIsType = toastTypes.includes(first as ToastType);
|
||||
const type = (firstIsType ? first : second) as ToastType;
|
||||
const title = (firstIsType ? second : first) as string;
|
||||
const id = Math.random().toString(36).substring(7);
|
||||
setToasts((prev) => [...prev, { id, type, title, description }]);
|
||||
|
||||
|
||||
@@ -6,3 +6,5 @@ export * from './DonutChart';
|
||||
export * from './EnhancedMetricCard';
|
||||
export * from './CommandPalette';
|
||||
export * from './StatusBadge';
|
||||
export { useToast } from '../hooks/use-toast';
|
||||
export type { ToastType, ToastContextValue } from '../hooks/use-toast';
|
||||
@@ -3,7 +3,7 @@ import { createContext, useContext } from 'react';
|
||||
export type ToastType = 'success' | 'info' | 'warning' | 'error';
|
||||
|
||||
export interface ToastContextValue {
|
||||
showToast: (message: string, type?: ToastType) => void;
|
||||
showToast: (first: ToastType | string, second: ToastType | string, description?: string) => void;
|
||||
}
|
||||
|
||||
export const ToastContext = createContext<ToastContextValue | null>(null);
|
||||
@@ -11,7 +11,7 @@ export const ToastContext = createContext<ToastContextValue | null>(null);
|
||||
export function useToast() {
|
||||
const context = useContext(ToastContext);
|
||||
if (!context) {
|
||||
throw new Error('useToast must be used within a ToastProvider');
|
||||
throw new Error('useToast must be used within ToastProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
Reference in new issue
Block a user