mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-11-01 16:29:29 +00:00
feat(api-key): show last used IP
This commit is contained in:
@@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
func TestAPIKeyFromService_MapsLastUsedAt(t *testing.T) {
|
func TestAPIKeyFromService_MapsLastUsedAt(t *testing.T) {
|
||||||
lastUsed := time.Now().UTC().Truncate(time.Second)
|
lastUsed := time.Now().UTC().Truncate(time.Second)
|
||||||
|
lastUsedIP := "203.0.113.10"
|
||||||
src := &service.APIKey{
|
src := &service.APIKey{
|
||||||
ID: 1,
|
ID: 1,
|
||||||
UserID: 2,
|
UserID: 2,
|
||||||
@@ -17,6 +18,7 @@ func TestAPIKeyFromService_MapsLastUsedAt(t *testing.T) {
|
|||||||
Name: "Mapper",
|
Name: "Mapper",
|
||||||
Status: service.StatusActive,
|
Status: service.StatusActive,
|
||||||
LastUsedAt: &lastUsed,
|
LastUsedAt: &lastUsed,
|
||||||
|
LastUsedIP: &lastUsedIP,
|
||||||
CurrentConcurrency: 3,
|
CurrentConcurrency: 3,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,6 +26,8 @@ func TestAPIKeyFromService_MapsLastUsedAt(t *testing.T) {
|
|||||||
require.NotNil(t, out)
|
require.NotNil(t, out)
|
||||||
require.NotNil(t, out.LastUsedAt)
|
require.NotNil(t, out.LastUsedAt)
|
||||||
require.WithinDuration(t, lastUsed, *out.LastUsedAt, time.Second)
|
require.WithinDuration(t, lastUsed, *out.LastUsedAt, time.Second)
|
||||||
|
require.NotNil(t, out.LastUsedIP)
|
||||||
|
require.Equal(t, lastUsedIP, *out.LastUsedIP)
|
||||||
require.Equal(t, 3, out.CurrentConcurrency)
|
require.Equal(t, 3, out.CurrentConcurrency)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,4 +43,5 @@ func TestAPIKeyFromService_MapsNilLastUsedAt(t *testing.T) {
|
|||||||
out := APIKeyFromService(src)
|
out := APIKeyFromService(src)
|
||||||
require.NotNil(t, out)
|
require.NotNil(t, out)
|
||||||
require.Nil(t, out.LastUsedAt)
|
require.Nil(t, out.LastUsedAt)
|
||||||
|
require.Nil(t, out.LastUsedIP)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ func APIKeyFromService(k *service.APIKey) *APIKey {
|
|||||||
IPWhitelist: k.IPWhitelist,
|
IPWhitelist: k.IPWhitelist,
|
||||||
IPBlacklist: k.IPBlacklist,
|
IPBlacklist: k.IPBlacklist,
|
||||||
LastUsedAt: k.LastUsedAt,
|
LastUsedAt: k.LastUsedAt,
|
||||||
|
LastUsedIP: k.LastUsedIP,
|
||||||
Quota: k.Quota,
|
Quota: k.Quota,
|
||||||
QuotaUsed: k.QuotaUsed,
|
QuotaUsed: k.QuotaUsed,
|
||||||
ExpiresAt: k.ExpiresAt,
|
ExpiresAt: k.ExpiresAt,
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ type APIKey struct {
|
|||||||
IPWhitelist []string `json:"ip_whitelist"`
|
IPWhitelist []string `json:"ip_whitelist"`
|
||||||
IPBlacklist []string `json:"ip_blacklist"`
|
IPBlacklist []string `json:"ip_blacklist"`
|
||||||
LastUsedAt *time.Time `json:"last_used_at"`
|
LastUsedAt *time.Time `json:"last_used_at"`
|
||||||
|
LastUsedIP *string `json:"last_used_ip"`
|
||||||
Quota float64 `json:"quota"` // Quota limit in USD (0 = unlimited)
|
Quota float64 `json:"quota"` // Quota limit in USD (0 = unlimited)
|
||||||
QuotaUsed float64 `json:"quota_used"` // Used quota amount in USD
|
QuotaUsed float64 `json:"quota_used"` // Used quota amount in USD
|
||||||
ExpiresAt *time.Time `json:"expires_at"` // Expiration time (nil = never expires)
|
ExpiresAt *time.Time `json:"expires_at"` // Expiration time (nil = never expires)
|
||||||
|
|||||||
@@ -14,9 +14,11 @@ import (
|
|||||||
"github.com/Wei-Shaw/sub2api/ent/schema/mixins"
|
"github.com/Wei-Shaw/sub2api/ent/schema/mixins"
|
||||||
"github.com/Wei-Shaw/sub2api/ent/user"
|
"github.com/Wei-Shaw/sub2api/ent/user"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
|
"github.com/lib/pq"
|
||||||
|
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||||
|
|
||||||
|
"entgo.io/ent/dialect"
|
||||||
entsql "entgo.io/ent/dialect/sql"
|
entsql "entgo.io/ent/dialect/sql"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -431,10 +433,96 @@ func (r *apiKeyRepository) ListByUserID(ctx context.Context, userID int64, param
|
|||||||
for i := range keys {
|
for i := range keys {
|
||||||
outKeys = append(outKeys, *apiKeyEntityToService(keys[i]))
|
outKeys = append(outKeys, *apiKeyEntityToService(keys[i]))
|
||||||
}
|
}
|
||||||
|
if err := r.attachLastUsedIPs(ctx, outKeys); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
return outKeys, paginationResultFromTotal(int64(total), params), nil
|
return outKeys, paginationResultFromTotal(int64(total), params), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *apiKeyRepository) attachLastUsedIPs(ctx context.Context, keys []service.APIKey) error {
|
||||||
|
if len(keys) == 0 || r.sql == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
apiKeyIDs := make([]int64, 0, len(keys))
|
||||||
|
for i := range keys {
|
||||||
|
apiKeyIDs = append(apiKeyIDs, keys[i].ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
lastUsedIPs, err := r.latestUsageLogIPs(ctx, apiKeyIDs)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for i := range keys {
|
||||||
|
if ip, ok := lastUsedIPs[keys[i].ID]; ok {
|
||||||
|
keys[i].LastUsedIP = &ip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *apiKeyRepository) latestUsageLogIPs(ctx context.Context, apiKeyIDs []int64) (map[int64]string, error) {
|
||||||
|
if len(apiKeyIDs) == 0 || r.sql == nil {
|
||||||
|
return map[int64]string{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
query, args := latestUsageLogIPsQuery(apiKeyIDs, r.client.Driver().Dialect())
|
||||||
|
rows, err := r.sql.QueryContext(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := make(map[int64]string, len(apiKeyIDs))
|
||||||
|
for rows.Next() {
|
||||||
|
var apiKeyID int64
|
||||||
|
var ipAddress string
|
||||||
|
if err := rows.Scan(&apiKeyID, &ipAddress); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out[apiKeyID] = ipAddress
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func latestUsageLogIPsQuery(apiKeyIDs []int64, dialectName string) (string, []any) {
|
||||||
|
if dialectName == dialect.Postgres {
|
||||||
|
return `
|
||||||
|
SELECT api_key_id, ip_address
|
||||||
|
FROM (
|
||||||
|
SELECT api_key_id, ip_address,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY api_key_id ORDER BY created_at DESC, id DESC) AS rn
|
||||||
|
FROM usage_logs
|
||||||
|
WHERE api_key_id = ANY($1::bigint[])
|
||||||
|
AND ip_address IS NOT NULL
|
||||||
|
AND ip_address <> ''
|
||||||
|
) ranked
|
||||||
|
WHERE rn = 1`, []any{pq.Array(apiKeyIDs)}
|
||||||
|
}
|
||||||
|
|
||||||
|
placeholders := make([]string, len(apiKeyIDs))
|
||||||
|
args := make([]any, len(apiKeyIDs))
|
||||||
|
for i, id := range apiKeyIDs {
|
||||||
|
placeholders[i] = "?"
|
||||||
|
args[i] = id
|
||||||
|
}
|
||||||
|
return fmt.Sprintf(`
|
||||||
|
SELECT api_key_id, ip_address
|
||||||
|
FROM (
|
||||||
|
SELECT api_key_id, ip_address,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY api_key_id ORDER BY created_at DESC, id DESC) AS rn
|
||||||
|
FROM usage_logs
|
||||||
|
WHERE api_key_id IN (%s)
|
||||||
|
AND ip_address IS NOT NULL
|
||||||
|
AND ip_address <> ''
|
||||||
|
) ranked
|
||||||
|
WHERE rn = 1`, strings.Join(placeholders, ", ")), args
|
||||||
|
}
|
||||||
|
|
||||||
func (r *apiKeyRepository) VerifyOwnership(ctx context.Context, userID int64, apiKeyIDs []int64) ([]int64, error) {
|
func (r *apiKeyRepository) VerifyOwnership(ctx context.Context, userID int64, apiKeyIDs []int64) ([]int64, error) {
|
||||||
if len(apiKeyIDs) == 0 {
|
if len(apiKeyIDs) == 0 {
|
||||||
return []int64{}, nil
|
return []int64{}, nil
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
dbent "github.com/Wei-Shaw/sub2api/ent"
|
dbent "github.com/Wei-Shaw/sub2api/ent"
|
||||||
"github.com/Wei-Shaw/sub2api/ent/enttest"
|
"github.com/Wei-Shaw/sub2api/ent/enttest"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
@@ -30,7 +31,7 @@ func newAPIKeyRepoSQLite(t *testing.T) (*apiKeyRepository, *dbent.Client) {
|
|||||||
client := enttest.NewClient(t, enttest.WithOptions(dbent.Driver(drv)))
|
client := enttest.NewClient(t, enttest.WithOptions(dbent.Driver(drv)))
|
||||||
t.Cleanup(func() { _ = client.Close() })
|
t.Cleanup(func() { _ = client.Close() })
|
||||||
|
|
||||||
return &apiKeyRepository{client: client}, client
|
return &apiKeyRepository{client: client, sql: db}, client
|
||||||
}
|
}
|
||||||
|
|
||||||
func mustCreateAPIKeyRepoUser(t *testing.T, ctx context.Context, client *dbent.Client, email string) *service.User {
|
func mustCreateAPIKeyRepoUser(t *testing.T, ctx context.Context, client *dbent.Client, email string) *service.User {
|
||||||
@@ -45,6 +46,85 @@ func mustCreateAPIKeyRepoUser(t *testing.T, ctx context.Context, client *dbent.C
|
|||||||
return userEntityToService(u)
|
return userEntityToService(u)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func mustCreateAPIKeyRepoAccount(t *testing.T, ctx context.Context, client *dbent.Client, name string) int64 {
|
||||||
|
t.Helper()
|
||||||
|
a, err := client.Account.Create().
|
||||||
|
SetName(name).
|
||||||
|
SetPlatform(service.PlatformOpenAI).
|
||||||
|
SetType(service.AccountTypeAPIKey).
|
||||||
|
SetStatus(service.StatusActive).
|
||||||
|
SetCredentials(map[string]any{"api_key": "sk-test"}).
|
||||||
|
Save(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return a.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustCreateAPIKeyRepoUsageLog(t *testing.T, ctx context.Context, client *dbent.Client, userID, apiKeyID, accountID int64, requestID string, createdAt time.Time, ipAddress *string) {
|
||||||
|
t.Helper()
|
||||||
|
builder := client.UsageLog.Create().
|
||||||
|
SetUserID(userID).
|
||||||
|
SetAPIKeyID(apiKeyID).
|
||||||
|
SetAccountID(accountID).
|
||||||
|
SetRequestID(requestID).
|
||||||
|
SetModel("gpt-5").
|
||||||
|
SetCreatedAt(createdAt)
|
||||||
|
if ipAddress != nil {
|
||||||
|
builder.SetIPAddress(*ipAddress)
|
||||||
|
}
|
||||||
|
_, err := builder.Save(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPIKeyRepositoryListByUserIDAttachesLastUsedIP(t *testing.T) {
|
||||||
|
repo, client := newAPIKeyRepoSQLite(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
user := mustCreateAPIKeyRepoUser(t, ctx, client, "list-last-used-ip@test.com")
|
||||||
|
accountID := mustCreateAPIKeyRepoAccount(t, ctx, client, "acc-list-last-used-ip")
|
||||||
|
|
||||||
|
withLogs := &service.APIKey{
|
||||||
|
UserID: user.ID,
|
||||||
|
Key: "sk-list-last-used-ip-logs",
|
||||||
|
Name: "With Logs",
|
||||||
|
Status: service.StatusActive,
|
||||||
|
}
|
||||||
|
emptyOnly := &service.APIKey{
|
||||||
|
UserID: user.ID,
|
||||||
|
Key: "sk-list-last-used-ip-empty",
|
||||||
|
Name: "Empty Only",
|
||||||
|
Status: service.StatusActive,
|
||||||
|
}
|
||||||
|
noLogs := &service.APIKey{
|
||||||
|
UserID: user.ID,
|
||||||
|
Key: "sk-list-last-used-ip-none",
|
||||||
|
Name: "No Logs",
|
||||||
|
Status: service.StatusActive,
|
||||||
|
}
|
||||||
|
require.NoError(t, repo.Create(ctx, withLogs))
|
||||||
|
require.NoError(t, repo.Create(ctx, emptyOnly))
|
||||||
|
require.NoError(t, repo.Create(ctx, noLogs))
|
||||||
|
|
||||||
|
olderIP := "198.51.100.10"
|
||||||
|
newerEmptyIP := ""
|
||||||
|
newestIP := "203.0.113.20"
|
||||||
|
base := time.Now().UTC().Add(-3 * time.Hour).Truncate(time.Second)
|
||||||
|
mustCreateAPIKeyRepoUsageLog(t, ctx, client, user.ID, withLogs.ID, accountID, "req-last-ip-older", base, &olderIP)
|
||||||
|
mustCreateAPIKeyRepoUsageLog(t, ctx, client, user.ID, withLogs.ID, accountID, "req-last-ip-empty", base.Add(time.Hour), &newerEmptyIP)
|
||||||
|
mustCreateAPIKeyRepoUsageLog(t, ctx, client, user.ID, withLogs.ID, accountID, "req-last-ip-newest", base.Add(2*time.Hour), &newestIP)
|
||||||
|
mustCreateAPIKeyRepoUsageLog(t, ctx, client, user.ID, emptyOnly.ID, accountID, "req-empty-ip", base.Add(3*time.Hour), &newerEmptyIP)
|
||||||
|
|
||||||
|
keys, _, err := repo.ListByUserID(ctx, user.ID, pagination.PaginationParams{Page: 1, PageSize: 10}, service.APIKeyListFilters{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
byID := make(map[int64]service.APIKey, len(keys))
|
||||||
|
for _, key := range keys {
|
||||||
|
byID[key.ID] = key
|
||||||
|
}
|
||||||
|
require.NotNil(t, byID[withLogs.ID].LastUsedIP)
|
||||||
|
require.Equal(t, newestIP, *byID[withLogs.ID].LastUsedIP)
|
||||||
|
require.Nil(t, byID[emptyOnly.ID].LastUsedIP)
|
||||||
|
require.Nil(t, byID[noLogs.ID].LastUsedIP)
|
||||||
|
}
|
||||||
|
|
||||||
func TestAPIKeyRepository_CreateWithLastUsedAt(t *testing.T) {
|
func TestAPIKeyRepository_CreateWithLastUsedAt(t *testing.T) {
|
||||||
repo, client := newAPIKeyRepoSQLite(t)
|
repo, client := newAPIKeyRepoSQLite(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|||||||
@@ -234,6 +234,7 @@ func TestAPIContracts(t *testing.T) {
|
|||||||
"ip_whitelist": null,
|
"ip_whitelist": null,
|
||||||
"ip_blacklist": null,
|
"ip_blacklist": null,
|
||||||
"last_used_at": null,
|
"last_used_at": null,
|
||||||
|
"last_used_ip": null,
|
||||||
"current_concurrency": 0,
|
"current_concurrency": 0,
|
||||||
"quota": 0,
|
"quota": 0,
|
||||||
"quota_used": 0,
|
"quota_used": 0,
|
||||||
@@ -284,6 +285,7 @@ func TestAPIContracts(t *testing.T) {
|
|||||||
"ip_whitelist": null,
|
"ip_whitelist": null,
|
||||||
"ip_blacklist": null,
|
"ip_blacklist": null,
|
||||||
"last_used_at": null,
|
"last_used_at": null,
|
||||||
|
"last_used_ip": null,
|
||||||
"current_concurrency": 0,
|
"current_concurrency": 0,
|
||||||
"quota": 0,
|
"quota": 0,
|
||||||
"quota_used": 0,
|
"quota_used": 0,
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ type APIKey struct {
|
|||||||
CompiledIPWhitelist *ip.CompiledIPRules `json:"-"`
|
CompiledIPWhitelist *ip.CompiledIPRules `json:"-"`
|
||||||
CompiledIPBlacklist *ip.CompiledIPRules `json:"-"`
|
CompiledIPBlacklist *ip.CompiledIPRules `json:"-"`
|
||||||
LastUsedAt *time.Time
|
LastUsedAt *time.Time
|
||||||
|
LastUsedIP *string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
User *User
|
User *User
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ export default {
|
|||||||
total: 'Last 30d',
|
total: 'Last 30d',
|
||||||
quota: 'Quota',
|
quota: 'Quota',
|
||||||
lastUsedAt: 'Last Used',
|
lastUsedAt: 'Last Used',
|
||||||
|
lastUsedIP: 'Last Used IP',
|
||||||
useKey: 'Use Key',
|
useKey: 'Use Key',
|
||||||
useKeyModal: {
|
useKeyModal: {
|
||||||
title: 'Use API Key',
|
title: 'Use API Key',
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ export default {
|
|||||||
total: '近30天',
|
total: '近30天',
|
||||||
quota: '额度',
|
quota: '额度',
|
||||||
lastUsedAt: '上次使用时间',
|
lastUsedAt: '上次使用时间',
|
||||||
|
lastUsedIP: '最近使用 IP',
|
||||||
useKey: '使用密钥',
|
useKey: '使用密钥',
|
||||||
useKeyModal: {
|
useKeyModal: {
|
||||||
title: '使用 API 密钥',
|
title: '使用 API 密钥',
|
||||||
|
|||||||
@@ -585,6 +585,7 @@ export interface ApiKey {
|
|||||||
ip_whitelist: string[]
|
ip_whitelist: string[]
|
||||||
ip_blacklist: string[]
|
ip_blacklist: string[]
|
||||||
last_used_at: string | null
|
last_used_at: string | null
|
||||||
|
last_used_ip: string | null
|
||||||
quota: number // Quota limit in USD (0 = unlimited)
|
quota: number // Quota limit in USD (0 = unlimited)
|
||||||
quota_used: number // Used quota amount in USD
|
quota_used: number // Used quota amount in USD
|
||||||
expires_at: string | null // Expiration time (null = never expires)
|
expires_at: string | null // Expiration time (null = never expires)
|
||||||
|
|||||||
@@ -354,6 +354,13 @@
|
|||||||
<span v-else class="text-sm text-gray-400 dark:text-dark-500">-</span>
|
<span v-else class="text-sm text-gray-400 dark:text-dark-500">-</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template #cell-last_used_ip="{ value }">
|
||||||
|
<span v-if="value" class="text-sm text-gray-500 dark:text-dark-400">
|
||||||
|
{{ value }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="text-sm text-gray-400 dark:text-dark-500">-</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
<template #cell-created_at="{ value }">
|
<template #cell-created_at="{ value }">
|
||||||
<span class="text-sm text-gray-500 dark:text-dark-400">{{ formatDateTime(value) }}</span>
|
<span class="text-sm text-gray-500 dark:text-dark-400">{{ formatDateTime(value) }}</span>
|
||||||
</template>
|
</template>
|
||||||
@@ -1174,15 +1181,19 @@ const allColumns = computed<Column[]>(() => [
|
|||||||
{ key: 'expires_at', label: t('keys.expiresAt'), sortable: true },
|
{ key: 'expires_at', label: t('keys.expiresAt'), sortable: true },
|
||||||
{ key: 'status', label: t('common.status'), sortable: true },
|
{ key: 'status', label: t('common.status'), sortable: true },
|
||||||
{ key: 'last_used_at', label: t('keys.lastUsedAt'), sortable: true },
|
{ key: 'last_used_at', label: t('keys.lastUsedAt'), sortable: true },
|
||||||
|
{ key: 'last_used_ip', label: t('keys.lastUsedIP'), sortable: false },
|
||||||
{ key: 'created_at', label: t('keys.created'), sortable: true },
|
{ key: 'created_at', label: t('keys.created'), sortable: true },
|
||||||
{ key: 'actions', label: t('common.actions'), sortable: false }
|
{ key: 'actions', label: t('common.actions'), sortable: false }
|
||||||
])
|
])
|
||||||
|
|
||||||
const ALWAYS_VISIBLE_COLUMNS = new Set(['name', 'actions'])
|
const ALWAYS_VISIBLE_COLUMNS = new Set(['name', 'actions'])
|
||||||
const DEFAULT_HIDDEN_COLUMNS = ['rate_limit', 'last_used_at']
|
const DEFAULT_HIDDEN_COLUMNS = ['rate_limit', 'last_used_at', 'last_used_ip']
|
||||||
const HIDDEN_COLUMNS_KEY = 'api-key-hidden-columns'
|
const HIDDEN_COLUMNS_KEY = 'api-key-hidden-columns'
|
||||||
const COLUMN_SETTINGS_VERSION_KEY = 'api-key-column-settings-version'
|
const COLUMN_SETTINGS_VERSION_KEY = 'api-key-column-settings-version'
|
||||||
const COLUMN_SETTINGS_VERSION = 1
|
const COLUMN_SETTINGS_VERSION = 2
|
||||||
|
const VERSION_NEW_HIDDEN_COLUMNS: Record<number, string[]> = {
|
||||||
|
2: ['last_used_ip']
|
||||||
|
}
|
||||||
|
|
||||||
const toggleableColumns = computed(() =>
|
const toggleableColumns = computed(() =>
|
||||||
allColumns.value.filter((col) => !ALWAYS_VISIBLE_COLUMNS.has(col.key))
|
allColumns.value.filter((col) => !ALWAYS_VISIBLE_COLUMNS.has(col.key))
|
||||||
@@ -1213,10 +1224,23 @@ const loadSavedColumns = () => {
|
|||||||
!ALWAYS_VISIBLE_COLUMNS.has(key)
|
!ALWAYS_VISIBLE_COLUMNS.has(key)
|
||||||
)
|
)
|
||||||
.forEach((key) => hiddenColumns.add(key))
|
.forEach((key) => hiddenColumns.add(key))
|
||||||
|
const storedVersion = Number(localStorage.getItem(COLUMN_SETTINGS_VERSION_KEY) ?? '1')
|
||||||
|
if (storedVersion < COLUMN_SETTINGS_VERSION) {
|
||||||
|
for (let v = storedVersion + 1; v <= COLUMN_SETTINGS_VERSION; v++) {
|
||||||
|
for (const key of VERSION_NEW_HIDDEN_COLUMNS[v] ?? []) {
|
||||||
|
if (validColumnKeys.has(key) && !ALWAYS_VISIBLE_COLUMNS.has(key)) {
|
||||||
|
hiddenColumns.add(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
saveColumnsToStorage()
|
||||||
|
} else {
|
||||||
|
localStorage.setItem(COLUMN_SETTINGS_VERSION_KEY, String(COLUMN_SETTINGS_VERSION))
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
DEFAULT_HIDDEN_COLUMNS.forEach((key) => hiddenColumns.add(key))
|
DEFAULT_HIDDEN_COLUMNS.forEach((key) => hiddenColumns.add(key))
|
||||||
|
localStorage.setItem(COLUMN_SETTINGS_VERSION_KEY, String(COLUMN_SETTINGS_VERSION))
|
||||||
}
|
}
|
||||||
localStorage.setItem(COLUMN_SETTINGS_VERSION_KEY, String(COLUMN_SETTINGS_VERSION))
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load API key table columns:', error)
|
console.error('Failed to load API key table columns:', error)
|
||||||
DEFAULT_HIDDEN_COLUMNS.forEach((key) => hiddenColumns.add(key))
|
DEFAULT_HIDDEN_COLUMNS.forEach((key) => hiddenColumns.add(key))
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ const messages: Record<string, string> = {
|
|||||||
'keys.group': 'Group',
|
'keys.group': 'Group',
|
||||||
'keys.currentConcurrency': 'Current Concurrency',
|
'keys.currentConcurrency': 'Current Concurrency',
|
||||||
'keys.lastUsedAt': 'Last Used',
|
'keys.lastUsedAt': 'Last Used',
|
||||||
|
'keys.lastUsedIP': 'Last Used IP',
|
||||||
'keys.rateLimitColumn': 'Rate Limit',
|
'keys.rateLimitColumn': 'Rate Limit',
|
||||||
'keys.searchPlaceholder': 'Search name or key...',
|
'keys.searchPlaceholder': 'Search name or key...',
|
||||||
'keys.status.active': 'Active',
|
'keys.status.active': 'Active',
|
||||||
@@ -113,6 +114,7 @@ const createApiKey = (): ApiKey => ({
|
|||||||
ip_whitelist: [],
|
ip_whitelist: [],
|
||||||
ip_blacklist: [],
|
ip_blacklist: [],
|
||||||
last_used_at: null,
|
last_used_at: null,
|
||||||
|
last_used_ip: null,
|
||||||
quota: 0,
|
quota: 0,
|
||||||
quota_used: 0,
|
quota_used: 0,
|
||||||
expires_at: null,
|
expires_at: null,
|
||||||
@@ -159,6 +161,12 @@ const DataTableStub = {
|
|||||||
<div data-test="current-concurrency">
|
<div data-test="current-concurrency">
|
||||||
<slot name="cell-current_concurrency" :value="row.current_concurrency" :row="row" />
|
<slot name="cell-current_concurrency" :value="row.current_concurrency" :row="row" />
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="columns.some((col) => col.key === 'last_used_ip')"
|
||||||
|
data-test="last-used-ip"
|
||||||
|
>
|
||||||
|
<slot name="cell-last_used_ip" :value="row.last_used_ip" :row="row" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<slot name="empty" />
|
<slot name="empty" />
|
||||||
</div>
|
</div>
|
||||||
@@ -265,6 +273,7 @@ describe('user KeysView column settings', () => {
|
|||||||
])
|
])
|
||||||
expect(visibleColumnKeys(wrapper)).not.toContain('rate_limit')
|
expect(visibleColumnKeys(wrapper)).not.toContain('rate_limit')
|
||||||
expect(visibleColumnKeys(wrapper)).not.toContain('last_used_at')
|
expect(visibleColumnKeys(wrapper)).not.toContain('last_used_at')
|
||||||
|
expect(visibleColumnKeys(wrapper)).not.toContain('last_used_ip')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows a hidden column when toggled and persists the preference', async () => {
|
it('shows a hidden column when toggled and persists the preference', async () => {
|
||||||
@@ -275,8 +284,28 @@ describe('user KeysView column settings', () => {
|
|||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
expect(visibleColumnKeys(wrapper)).toContain('rate_limit')
|
expect(visibleColumnKeys(wrapper)).toContain('rate_limit')
|
||||||
expect(localStorage.getItem('api-key-hidden-columns')).toBe(JSON.stringify(['last_used_at']))
|
expect(localStorage.getItem('api-key-hidden-columns')).toBe(
|
||||||
expect(localStorage.getItem('api-key-column-settings-version')).toBe('1')
|
JSON.stringify(['last_used_at', 'last_used_ip'])
|
||||||
|
)
|
||||||
|
expect(localStorage.getItem('api-key-column-settings-version')).toBe('2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the last used IP column when toggled', async () => {
|
||||||
|
listKeys.mockResolvedValueOnce({
|
||||||
|
items: [{ ...createApiKey(), last_used_ip: '203.0.113.10' }],
|
||||||
|
total: 1,
|
||||||
|
page: 1,
|
||||||
|
page_size: 20,
|
||||||
|
pages: 1,
|
||||||
|
})
|
||||||
|
const wrapper = await mountView()
|
||||||
|
|
||||||
|
await wrapper.get('button[title="Column Settings"]').trigger('click')
|
||||||
|
await getButtonByText(wrapper, 'Last Used IP').trigger('click')
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(visibleColumnKeys(wrapper)).toContain('last_used_ip')
|
||||||
|
expect(wrapper.get('[data-test="last-used-ip"]').text()).toBe('203.0.113.10')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('restores column preferences from localStorage on mount', async () => {
|
it('restores column preferences from localStorage on mount', async () => {
|
||||||
@@ -296,6 +325,10 @@ describe('user KeysView column settings', () => {
|
|||||||
'last_used_at',
|
'last_used_at',
|
||||||
'actions',
|
'actions',
|
||||||
])
|
])
|
||||||
|
expect(localStorage.getItem('api-key-hidden-columns')).toBe(
|
||||||
|
JSON.stringify(['group', 'created_at', 'last_used_ip'])
|
||||||
|
)
|
||||||
|
expect(localStorage.getItem('api-key-column-settings-version')).toBe('2')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('does not include always-visible columns in the toggleable menu', async () => {
|
it('does not include always-visible columns in the toggleable menu', async () => {
|
||||||
@@ -308,6 +341,7 @@ describe('user KeysView column settings', () => {
|
|||||||
expect(columnMenuText).toContain('API Key')
|
expect(columnMenuText).toContain('API Key')
|
||||||
expect(columnMenuText).toContain('Current Concurrency')
|
expect(columnMenuText).toContain('Current Concurrency')
|
||||||
expect(columnMenuText).toContain('Rate Limit')
|
expect(columnMenuText).toContain('Rate Limit')
|
||||||
|
expect(columnMenuText).toContain('Last Used IP')
|
||||||
expect(columnMenuText).not.toContain('Name')
|
expect(columnMenuText).not.toContain('Name')
|
||||||
expect(columnMenuText).not.toContain('Actions')
|
expect(columnMenuText).not.toContain('Actions')
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user