feat(dify-ui): add status and progress primitives (#36615)

This commit is contained in:
yyh 2026-05-25 16:31:52 +08:00 committed by GitHub
parent 9ddd98a265
commit 23539c5bcc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
80 changed files with 841 additions and 679 deletions

View File

@ -2534,7 +2534,7 @@
},
"web/app/components/header/account-setting/model-provider-page/model-auth/switch-credential-in-load-balancing.tsx": {
"ts/no-explicit-any": {
"count": 3
"count": 2
}
},
"web/app/components/header/account-setting/model-provider-page/model-modal/Input.tsx": {
@ -2653,11 +2653,6 @@
"count": 1
}
},
"web/app/components/plugins/plugin-auth/authorized-in-node.tsx": {
"ts/no-explicit-any": {
"count": 1
}
},
"web/app/components/plugins/plugin-auth/authorized/index.tsx": {
"no-restricted-imports": {
"count": 1
@ -2684,11 +2679,6 @@
"count": 3
}
},
"web/app/components/plugins/plugin-auth/plugin-auth-in-agent.tsx": {
"ts/no-explicit-any": {
"count": 1
}
},
"web/app/components/plugins/plugin-auth/types.ts": {
"erasable-syntax-only/enums": {
"count": 2

View File

@ -73,6 +73,10 @@
"types": "./src/meter/index.tsx",
"import": "./src/meter/index.tsx"
},
"./progress": {
"types": "./src/progress/index.tsx",
"import": "./src/progress/index.tsx"
},
"./number-field": {
"types": "./src/number-field/index.tsx",
"import": "./src/number-field/index.tsx"
@ -105,6 +109,10 @@
"types": "./src/select/index.tsx",
"import": "./src/select/index.tsx"
},
"./status-dot": {
"types": "./src/status-dot/index.tsx",
"import": "./src/status-dot/index.tsx"
},
"./slider": {
"types": "./src/slider/index.tsx",
"import": "./src/slider/index.tsx"

View File

@ -0,0 +1,80 @@
import { render } from 'vitest-browser-react'
import { ProgressCircle } from '../index'
describe('ProgressCircle', () => {
it('exposes progressbar semantics through Base UI Progress', async () => {
const screen = await render(<ProgressCircle value={40} aria-label="Uploading" />)
const progress = screen.getByLabelText('Uploading')
await expect.element(progress).toHaveAttribute('role', 'progressbar')
await expect.element(progress).toHaveAttribute('aria-valuemin', '0')
await expect.element(progress).toHaveAttribute('aria-valuemax', '100')
await expect.element(progress).toHaveAttribute('aria-valuenow', '40')
})
it('supports custom min and max', async () => {
const screen = await render(<ProgressCircle value={3} min={1} max={5} aria-label="Installing" />)
const progress = screen.getByLabelText('Installing')
await expect.element(progress).toHaveAttribute('aria-valuemin', '1')
await expect.element(progress).toHaveAttribute('aria-valuemax', '5')
await expect.element(progress).toHaveAttribute('aria-valuenow', '3')
})
it('renders indeterminate state when value is null', async () => {
const screen = await render(<ProgressCircle value={null} aria-label="Processing" data-testid="progress" />)
await expect.element(screen.getByTestId('progress')).toHaveAttribute('data-indeterminate')
await expect.element(screen.getByTestId('progress')).not.toHaveAttribute('aria-valuenow')
expect(screen.getByTestId('progress').element().querySelector('path')).toBeNull()
})
it('does not render a progress sector for zero progress', async () => {
const screen = await render(<ProgressCircle value={0} aria-label="Uploading" data-testid="progress" />)
expect(screen.getByTestId('progress').element().querySelector('path')).toBeNull()
})
it('applies design kit size variants', async () => {
const screen = await render(<ProgressCircle value={50} size="large" aria-label="Uploading" data-testid="progress" />)
const root = screen.getByTestId('progress').element() as HTMLElement
const svg = root.querySelector('svg')!
expect(root.className).toContain('size-5')
expect(svg.getAttribute('width')).toBe('21')
expect(svg.getAttribute('height')).toBe('21')
})
it('applies color tokens to circle and sector', async () => {
const screen = await render(<ProgressCircle value={50} color="error" aria-label="Uploading" data-testid="progress" />)
const root = screen.getByTestId('progress').element() as HTMLElement
const circle = root.querySelector('circle')!
const path = root.querySelector('path')!
expect(circle.getAttribute('class')).toContain('fill-components-progress-error-bg')
expect(circle.getAttribute('class')).toContain('stroke-components-progress-error-border')
expect(path.getAttribute('class')).toContain('fill-components-progress-error-progress')
})
it('renders a deterministic progress sector', async () => {
const screen = await render(<ProgressCircle value={75} aria-label="Uploading" data-testid="progress" />)
const path = screen.getByTestId('progress').element().querySelector('path')!
expect(path.getAttribute('d')).toContain('A 6,6 0 1 1')
})
it('renders a closed circle sector for complete progress', async () => {
const screen = await render(<ProgressCircle value={100} aria-label="Uploading" data-testid="progress" />)
const path = screen.getByTestId('progress').element().querySelector('path')!
const pathData = path.getAttribute('d')!
expect(pathData).toContain('A 6,6 0 1 1 6,12')
expect(pathData).toContain('A 6,6 0 1 1 6,0')
})
})

View File

@ -0,0 +1,77 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import type { ProgressCircleColor, ProgressCircleSize } from '.'
import { Fragment } from 'react'
import { ProgressCircle } from '.'
const colors: ProgressCircleColor[] = ['gray', 'white', 'blue', 'warning', 'error']
const sizes: ProgressCircleSize[] = ['small', 'medium', 'large']
const meta = {
title: 'Base/UI/Progress',
component: ProgressCircle,
parameters: {
layout: 'centered',
docs: {
description: {
component:
'Task progress primitives. ProgressCircle matches the Dify Design Kit circular Progress component and uses Base UI Progress semantics.',
},
},
},
tags: ['autodocs'],
} satisfies Meta<typeof ProgressCircle>
export default meta
type Story = StoryObj<typeof meta>
export const Circle: Story = {
args: {
'value': 42,
'color': 'blue',
'size': 'small',
'aria-label': 'Uploading',
},
}
export const CircleMatrix: Story = {
args: {
'value': 62,
'aria-label': 'Progress',
},
render: () => (
<div className="grid grid-cols-[auto_auto_auto_auto] items-center gap-4 rounded-lg bg-components-panel-bg p-4">
<div />
{sizes.map(size => (
<div key={size} className="system-xs-medium text-text-tertiary">
{size}
</div>
))}
{colors.map(color => (
<Fragment key={color}>
<div className="system-xs-semibold-uppercase text-text-secondary">
{color}
</div>
{sizes.map(size => (
<ProgressCircle
key={`${color}-${size}`}
value={62}
color={color}
size={size}
aria-label={`${color} ${size} progress`}
/>
))}
</Fragment>
))}
</div>
),
}
export const Indeterminate: Story = {
args: {
'value': null,
'color': 'gray',
'size': 'medium',
'aria-label': 'Processing',
},
}

View File

@ -0,0 +1,167 @@
'use client'
import type { VariantProps } from 'class-variance-authority'
import { Progress as BaseProgress } from '@base-ui/react/progress'
import { cva } from 'class-variance-authority'
import { cn } from '../cn'
const progressCircleRootVariants = cva(
'inline-flex shrink-0 items-center justify-center',
{
variants: {
size: {
small: 'size-3',
medium: 'size-4',
large: 'size-5',
},
},
defaultVariants: {
size: 'small',
},
},
)
const progressCircleColorClasses = {
gray: {
stroke: 'stroke-components-progress-gray-border',
fill: 'fill-components-progress-gray-bg',
sector: 'fill-components-progress-gray-progress',
},
white: {
stroke: 'stroke-components-progress-white-border',
fill: 'fill-components-progress-white-bg',
sector: 'fill-components-progress-white-progress',
},
blue: {
stroke: 'stroke-components-progress-brand-border',
fill: 'fill-components-progress-brand-bg',
sector: 'fill-components-progress-brand-progress',
},
warning: {
stroke: 'stroke-components-progress-warning-border',
fill: 'fill-components-progress-warning-bg',
sector: 'fill-components-progress-warning-progress',
},
error: {
stroke: 'stroke-components-progress-error-border',
fill: 'fill-components-progress-error-bg',
sector: 'fill-components-progress-error-progress',
},
} as const
export type ProgressCircleSize = NonNullable<VariantProps<typeof progressCircleRootVariants>['size']>
export type ProgressCircleColor = keyof typeof progressCircleColorClasses
const progressCircleSizeValues = {
small: 12,
medium: 16,
large: 20,
} as const satisfies Record<ProgressCircleSize, number>
type ProgressCircleAccessibleNameProps
= | {
'aria-label': string
'aria-labelledby'?: never
}
| {
'aria-label'?: never
'aria-labelledby': string
}
export type ProgressCircleProps
= Omit<BaseProgress.Root.Props, 'children' | 'className' | 'aria-label' | 'aria-labelledby'>
& ProgressCircleAccessibleNameProps
& {
className?: string
color?: ProgressCircleColor
size?: ProgressCircleSize
circleStrokeWidth?: number
}
function getProgressPercentage(value: number | null, min: number, max: number) {
if (value === null || !Number.isFinite(value) || max <= min)
return null
return Math.min(100, Math.max(0, ((value - min) / (max - min)) * 100))
}
function getSectorPath(size: number, percentage: number | null) {
if (percentage === null || percentage <= 0)
return ''
const radius = size / 2
const center = size / 2
if (percentage >= 100) {
return `
M ${center},${center - radius}
A ${radius},${radius} 0 1 1 ${center},${center + radius}
A ${radius},${radius} 0 1 1 ${center},${center - radius}
Z
`
}
const angle = (percentage / 100) * 360
const radians = (angle * Math.PI) / 180
const x = center + radius * Math.cos(radians - Math.PI / 2)
const y = center + radius * Math.sin(radians - Math.PI / 2)
const largeArcFlag = percentage > 50 ? 1 : 0
return `
M ${center},${center}
L ${center},${center - radius}
A ${radius},${radius} 0 ${largeArcFlag} 1 ${x},${y}
Z
`
}
export function ProgressCircle({
className,
color = 'blue',
size = 'small',
circleStrokeWidth = 1,
value,
min = 0,
max = 100,
...props
}: ProgressCircleProps) {
const numericSize = progressCircleSizeValues[size]
const percentage = getProgressPercentage(value, min, max)
const radius = numericSize / 2
const center = numericSize / 2
const pathData = getSectorPath(numericSize, percentage)
const colors = progressCircleColorClasses[color]
return (
<BaseProgress.Root
className={cn(progressCircleRootVariants({ size }), className)}
value={value}
min={min}
max={max}
{...props}
>
<svg
width={numericSize + circleStrokeWidth}
height={numericSize + circleStrokeWidth}
viewBox={`0 0 ${numericSize + circleStrokeWidth} ${numericSize + circleStrokeWidth}`}
aria-hidden="true"
className="block"
>
<circle
className={cn(colors.fill, colors.stroke)}
cx={center + circleStrokeWidth / 2}
cy={center + circleStrokeWidth / 2}
r={radius}
strokeWidth={circleStrokeWidth}
/>
{pathData && (
<path
className={colors.sector}
d={pathData}
transform={`translate(${circleStrokeWidth / 2}, ${circleStrokeWidth / 2})`}
/>
)}
</svg>
</BaseProgress.Root>
)
}

View File

@ -0,0 +1,57 @@
import { render } from 'vitest-browser-react'
import { StatusDot, StatusDotSkeleton } from '../index'
describe('StatusDot', () => {
it('renders a medium success dot by default', async () => {
const screen = await render(<StatusDot data-testid="dot" />)
const root = screen.getByTestId('dot').element() as HTMLElement
await expect.element(screen.getByTestId('dot')).toHaveAttribute('aria-hidden', 'true')
expect(root.className).toContain('size-2')
expect(root.className).toContain('bg-components-badge-status-light-success-bg')
expect(root.className).toContain('border-components-badge-status-light-success-border-inner')
expect(root.className).toContain('shadow-status-indicator-green-shadow')
})
it('uses small dot geometry', async () => {
const screen = await render(<StatusDot size="small" data-testid="dot" />)
const root = screen.getByTestId('dot').element() as HTMLElement
expect(root.className).toContain('size-1.5')
expect(root.className).toContain('rounded-xs')
})
it.each([
['warning', 'bg-components-badge-status-light-warning-bg', 'border-components-badge-status-light-warning-border-inner'],
['error', 'bg-components-badge-status-light-error-bg', 'border-components-badge-status-light-error-border-inner'],
['normal', 'bg-components-badge-status-light-normal-bg', 'border-components-badge-status-light-normal-border-inner'],
['disabled', 'bg-components-badge-status-light-disabled-bg', 'border-components-badge-status-light-disabled-border-inner'],
] as const)('applies %s status tokens', async (status, backgroundClass, borderClass) => {
const screen = await render(<StatusDot status={status} data-testid="dot" />)
const dot = screen.getByTestId('dot').element() as HTMLElement
expect(dot.className).toContain(backgroundClass)
expect(dot.className).toContain(borderClass)
})
it('keeps an explicit accessible label visible to assistive tech', async () => {
const screen = await render(<StatusDot aria-label="Active" data-testid="dot" />)
await expect.element(screen.getByTestId('dot')).toHaveAttribute('aria-label', 'Active')
await expect.element(screen.getByTestId('dot')).not.toHaveAttribute('aria-hidden')
})
it('renders skeleton styling without status color', async () => {
const screen = await render(<StatusDotSkeleton data-testid="dot" />)
const dot = screen.getByTestId('dot').element() as HTMLElement
expect(dot.className).toContain('bg-text-primary')
expect(dot.className).toContain('opacity-30')
expect(dot.className).not.toContain('bg-components-badge-status-light-success-bg')
expect(dot.className).not.toContain('border-components-badge-status-light-success-border-inner')
})
})

View File

@ -0,0 +1,62 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import type { StatusDotSize, StatusDotStatus } from '.'
import { Fragment } from 'react'
import { StatusDot, StatusDotSkeleton } from '.'
const statuses: StatusDotStatus[] = ['success', 'warning', 'error', 'normal', 'disabled']
const sizes: StatusDotSize[] = ['small', 'medium']
const meta = {
title: 'Base/UI/StatusDot',
component: StatusDot,
parameters: {
layout: 'centered',
docs: {
description: {
component:
'Status Dot primitive from the Dify Design Kit. Use it for compact visual status indicators; provide an accessible label only when the dot is the sole status representation.',
},
},
},
tags: ['autodocs'],
} satisfies Meta<typeof StatusDot>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
status: 'success',
size: 'medium',
},
}
export const Matrix: Story = {
render: () => (
<div className="grid grid-cols-[auto_auto_auto] items-center gap-4">
<div />
<div className="system-xs-medium text-text-tertiary">Small</div>
<div className="system-xs-medium text-text-tertiary">Medium</div>
{statuses.map(status => (
<Fragment key={status}>
<div className="system-xs-semibold-uppercase text-text-secondary">
{status}
</div>
{sizes.map(size => (
<StatusDot key={`${status}-${size}`} status={status} size={size} />
))}
</Fragment>
))}
</div>
),
}
export const Skeleton: Story = {
render: () => (
<div className="flex items-center gap-4">
<StatusDotSkeleton size="small" />
<StatusDotSkeleton size="medium" />
</div>
),
}

View File

@ -0,0 +1,108 @@
'use client'
import type { VariantProps } from 'class-variance-authority'
import type { ComponentProps } from 'react'
import { cva } from 'class-variance-authority'
import { cn } from '../cn'
const statusDotVariants = cva(
'block shrink-0 border border-solid',
{
variants: {
status: {
success: 'border-components-badge-status-light-success-border-inner bg-components-badge-status-light-success-bg shadow-status-indicator-green-shadow',
warning: 'border-components-badge-status-light-warning-border-inner bg-components-badge-status-light-warning-bg shadow-status-indicator-warning-shadow',
error: 'border-components-badge-status-light-error-border-inner bg-components-badge-status-light-error-bg shadow-status-indicator-red-shadow',
normal: 'border-components-badge-status-light-normal-border-inner bg-components-badge-status-light-normal-bg shadow-status-indicator-blue-shadow',
disabled: 'border-components-badge-status-light-disabled-border-inner bg-components-badge-status-light-disabled-bg shadow-status-indicator-gray-shadow',
},
size: {
small: 'size-1.5 rounded-xs',
medium: 'size-2 rounded-[3px]',
},
},
defaultVariants: {
status: 'success',
size: 'medium',
},
},
)
const statusDotSkeletonVariants = cva(
'block shrink-0 border border-transparent bg-text-primary opacity-30',
{
variants: {
size: {
small: 'size-1.5 rounded-xs',
medium: 'size-2 rounded-[3px]',
},
},
defaultVariants: {
size: 'medium',
},
},
)
type StatusDotVariants = VariantProps<typeof statusDotVariants>
export type StatusDotStatus = NonNullable<StatusDotVariants['status']>
export type StatusDotSize = NonNullable<StatusDotVariants['size']>
export type StatusDotProps
= Omit<ComponentProps<'span'>, 'children'>
& {
status?: StatusDotStatus
size?: StatusDotSize
}
export type StatusDotSkeletonProps
= Omit<ComponentProps<'span'>, 'children'>
& {
size?: StatusDotSize
}
export function StatusDot({
className,
status = 'success',
size = 'medium',
'aria-hidden': ariaHidden,
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledBy,
...props
}: StatusDotProps) {
const hidden = ariaHidden ?? (ariaLabel || ariaLabelledBy ? undefined : true)
return (
<span
className={cn(
statusDotVariants({ status, size }),
className,
)}
aria-hidden={hidden}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
{...props}
/>
)
}
export function StatusDotSkeleton({
className,
size = 'medium',
'aria-hidden': ariaHidden,
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledBy,
...props
}: StatusDotSkeletonProps) {
const hidden = ariaHidden ?? (ariaLabel || ariaLabelledBy ? undefined : true)
return (
<span
className={cn(statusDotSkeletonVariants({ size }), className)}
aria-hidden={hidden}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
{...props}
/>
)
}

View File

@ -143,10 +143,6 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/declaration
ConfigurationMethodEnum: { predefinedModel: 'predefined-model' },
}))
vi.mock('@/app/components/header/indicator', () => ({
default: ({ color }: { color: string }) => <span data-testid={`indicator-${color}`} />,
}))
vi.mock('@/app/components/plugins/card/base/card-icon', () => ({
default: ({ src }: { src: string }) => <div data-testid="card-icon" data-src={typeof src === 'string' ? src : 'emoji'} />,
}))
@ -282,7 +278,7 @@ describe('Tool Provider Detail Flow Integration', () => {
await waitFor(() => {
expect(screen.getByText('Authorized')).toBeInTheDocument()
expect(screen.getByTestId('indicator-green')).toBeInTheDocument()
expect(document.querySelector('.shadow-status-indicator-green-shadow')).toBeInTheDocument()
})
})

View File

@ -2,6 +2,7 @@
import type { FC, JSX } from 'react'
import type { AliyunConfig, ArizeConfig, DatabricksConfig, LangFuseConfig, LangSmithConfig, MLflowConfig, OpikConfig, PhoenixConfig, TencentConfig, WeaveConfig } from './type'
import { cn } from '@langgenius/dify-ui/cn'
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import { Switch } from '@langgenius/dify-ui/switch'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useBoolean } from 'ahooks'
@ -9,7 +10,6 @@ import * as React from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import Indicator from '@/app/components/header/indicator'
import ProviderConfigModal from './provider-config-modal'
import ProviderPanel from './provider-panel'
import TracingIcon from './tracing-icon'
@ -330,7 +330,7 @@ const ConfigPopup: FC<PopupProps> = ({
<div className="title-2xl-semi-bold text-text-primary">{t(`${I18N_PREFIX}.tracing`, { ns: 'app' })}</div>
</div>
<div className="flex items-center">
<Indicator color={enabled ? 'green' : 'gray'} />
<StatusDot status={enabled ? 'success' : 'disabled'} />
<div className={cn('ml-1 system-xs-semibold-uppercase text-text-tertiary', enabled && 'text-util-colors-green-green-600')}>
{t(`${I18N_PREFIX}.${enabled ? 'enabled' : 'disabled'}`, { ns: 'app' })}
</div>

View File

@ -3,6 +3,7 @@ import type { FC } from 'react'
import type { AliyunConfig, ArizeConfig, DatabricksConfig, LangFuseConfig, LangSmithConfig, MLflowConfig, OpikConfig, PhoenixConfig, TencentConfig, WeaveConfig } from './type'
import type { TracingStatus } from '@/models/app'
import { cn } from '@langgenius/dify-ui/cn'
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import { toast } from '@langgenius/dify-ui/toast'
import {
RiArrowDownDoubleLine,
@ -15,7 +16,6 @@ import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import { AliyunIcon, ArizeIcon, DatabricksIcon, LangfuseIcon, LangsmithIcon, MlflowIcon, OpikIcon, PhoenixIcon, TencentIcon, WeaveIcon } from '@/app/components/base/icons/src/public/tracing'
import Loading from '@/app/components/base/loading'
import Indicator from '@/app/components/header/indicator'
import { useAppContext } from '@/context/app-context'
import { usePathname } from '@/next/navigation'
import { fetchTracingConfig as doFetchTracingConfig, fetchTracingStatus, updateTracingStatus } from '@/service/apps'
@ -290,7 +290,7 @@ const Panel: FC = () => {
)}
>
<div className="mr-1 ml-4 flex items-center">
<Indicator color={enabled ? 'green' : 'gray'} />
<StatusDot status={enabled ? 'success' : 'disabled'} />
<div className="ml-1.5 system-xs-semibold-uppercase text-text-tertiary">
{t(`${I18N_PREFIX}.${enabled ? 'enabled' : 'disabled'}`, { ns: 'app' })}
</div>

View File

@ -7,6 +7,7 @@ import type { AgentTool } from '@/types/app'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import { Switch } from '@langgenius/dify-ui/switch'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import {
@ -24,7 +25,6 @@ import AppIcon from '@/app/components/base/app-icon'
import { DefaultToolIcon } from '@/app/components/base/icons/src/public/other'
import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback'
import { Infotip } from '@/app/components/base/infotip'
import Indicator from '@/app/components/header/indicator'
import { CollectionType } from '@/app/components/tools/types'
import { addDefaultValue, toolParametersToFormSchemas } from '@/app/components/tools/utils/to-form-schema'
import ToolPicker from '@/app/components/workflow/block-selector/tool-picker'
@ -340,7 +340,7 @@ const AgentTools: FC = () => {
}}
>
{t('notAuthorized', { ns: 'tools' })}
<Indicator className="ml-2" color="orange" />
<StatusDot className="ml-2" status="warning" />
</Button>
)}
</div>

View File

@ -17,6 +17,7 @@ import {
DrawerPortal,
DrawerViewport,
} from '@langgenius/dify-ui/drawer'
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import { toast } from '@langgenius/dify-ui/toast'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { RiCloseLine, RiEditFill } from '@remixicon/react'
@ -47,7 +48,6 @@ import { AppSourceType } from '@/service/share'
import { useChatConversationDetail, useCompletionConversationDetail } from '@/service/use-log'
import { AppModeEnum } from '@/types/app'
import PromptLogModal from '../../base/prompt-log-modal'
import Indicator from '../../header/indicator'
import {
applyAnnotationAdded,
applyAnnotationEdited,
@ -114,7 +114,7 @@ const statusTdRender = (statusCount: StatusCount) => {
if (statusCount.paused > 0) {
return (
<div className="inline-flex items-center gap-1 system-xs-semibold-uppercase">
<Indicator color="yellow" />
<StatusDot status="warning" />
<span className="text-util-colors-warning-warning-600">Pending</span>
</div>
)
@ -122,7 +122,7 @@ const statusTdRender = (statusCount: StatusCount) => {
else if (statusCount.partial_success + statusCount.failed === 0) {
return (
<div className="inline-flex items-center gap-1 system-xs-semibold-uppercase">
<Indicator color="green" />
<StatusDot status="success" />
<span className="text-util-colors-green-green-600">Success</span>
</div>
)
@ -130,7 +130,7 @@ const statusTdRender = (statusCount: StatusCount) => {
else if (statusCount.failed === 0) {
return (
<div className="inline-flex items-center gap-1 system-xs-semibold-uppercase">
<Indicator color="green" />
<StatusDot status="success" />
<span className="text-util-colors-green-green-600">Partial Success</span>
</div>
)
@ -138,7 +138,7 @@ const statusTdRender = (statusCount: StatusCount) => {
else {
return (
<div className="inline-flex items-center gap-1 system-xs-semibold-uppercase">
<Indicator color="red" />
<StatusDot status="error" />
<span className="text-util-colors-red-red-600">
{statusCount.failed}
{' '}

View File

@ -4,6 +4,7 @@ import type { ConfigParams } from './settings'
import type { AppDetailResponse } from '@/models/app'
import type { AppSSO } from '@/types/app'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import { Switch } from '@langgenius/dify-ui/switch'
import { useSuspenseQuery } from '@tanstack/react-query'
import * as React from 'react'
@ -12,7 +13,6 @@ import { useTranslation } from 'react-i18next'
import AppBasic from '@/app/components/app-sidebar/basic'
import { useStore as useAppStore } from '@/app/components/app/store'
import SecretKeyButton from '@/app/components/develop/secret-key/secret-key-button'
import Indicator from '@/app/components/header/indicator'
import { useAppContext } from '@/context/app-context'
import { useDocLink } from '@/context/i18n'
import { AccessMode } from '@/models/access-control'
@ -296,7 +296,7 @@ function AppCard({
}
/>
<div className="flex shrink-0 items-center gap-1">
<Indicator color={cardState.runningStatus ? 'green' : 'yellow'} />
<StatusDot status={cardState.runningStatus ? 'success' : 'warning'} />
<div className={`${cardState.runningStatus ? 'text-text-success' : 'text-text-warning'} system-xs-semibold-uppercase`}>
{cardState.runningStatus
? t('overview.status.running', { ns: 'appOverview' })

View File

@ -3,6 +3,7 @@ import type { AppDetailResponse } from '@/models/app'
import type { AppTrigger } from '@/service/use-tools'
import type { AppSSO } from '@/types/app'
import type { I18nKeysByPrefix } from '@/types/i18n'
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import { Switch } from '@langgenius/dify-ui/switch'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
@ -30,20 +31,6 @@ type ITriggerCardProps = {
const getTriggerIcon = (trigger: AppTrigger, triggerPlugins: any[]) => {
const { trigger_type, status, provider_name } = trigger
// Status dot styling based on trigger status
const getStatusDot = () => {
if (status === 'enabled') {
return (
<div className="absolute -top-0.5 -left-0.5 size-1.5 rounded-xs border border-black/15 bg-green-500" />
)
}
else {
return (
<div className="absolute -top-0.5 -left-0.5 size-1.5 rounded-xs border border-components-badge-status-light-disabled-border-inner bg-components-badge-status-light-disabled-bg shadow-status-indicator-gray-shadow" />
)
}
}
// Get BlockEnum type from trigger_type
let blockType: BlockEnum
switch (trigger_type) {
@ -78,7 +65,11 @@ const getTriggerIcon = (trigger: AppTrigger, triggerPlugins: any[]) => {
size="md"
toolIcon={triggerIcon}
/>
{getStatusDot()}
<StatusDot
className="absolute -top-0.5 -left-0.5"
size="small"
status={status === 'enabled' ? 'success' : 'disabled'}
/>
</div>
)
}

View File

@ -12,11 +12,11 @@ import {
DrawerPortal,
DrawerViewport,
} from '@langgenius/dify-ui/drawer'
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import * as React from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
import Indicator from '@/app/components/header/indicator'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import useTimestamp from '@/hooks/use-timestamp'
import { AppModeEnum } from '@/types/app'
@ -67,7 +67,7 @@ const WorkflowAppLogList: FC<ILogs> = ({ logs, appDetail, onRefresh }) => {
if (status === 'succeeded') {
return (
<div className="inline-flex items-center gap-1 system-xs-semibold-uppercase">
<Indicator color="green" />
<StatusDot status="success" />
<span className="text-util-colors-green-green-600">Success</span>
</div>
)
@ -75,7 +75,7 @@ const WorkflowAppLogList: FC<ILogs> = ({ logs, appDetail, onRefresh }) => {
if (status === 'failed') {
return (
<div className="inline-flex items-center gap-1 system-xs-semibold-uppercase">
<Indicator color="red" />
<StatusDot status="error" />
<span className="text-util-colors-red-red-600">Failure</span>
</div>
)
@ -83,7 +83,7 @@ const WorkflowAppLogList: FC<ILogs> = ({ logs, appDetail, onRefresh }) => {
if (status === 'stopped') {
return (
<div className="inline-flex items-center gap-1 system-xs-semibold-uppercase">
<Indicator color="yellow" />
<StatusDot status="warning" />
<span className="text-util-colors-warning-warning-600">Stop</span>
</div>
)
@ -91,7 +91,7 @@ const WorkflowAppLogList: FC<ILogs> = ({ logs, appDetail, onRefresh }) => {
if (status === 'paused') {
return (
<div className="inline-flex items-center gap-1 system-xs-semibold-uppercase">
<Indicator color="yellow" />
<StatusDot status="warning" />
<span className="text-util-colors-warning-warning-600">Pending</span>
</div>
)
@ -99,7 +99,7 @@ const WorkflowAppLogList: FC<ILogs> = ({ logs, appDetail, onRefresh }) => {
if (status === 'running') {
return (
<div className="inline-flex items-center gap-1 system-xs-semibold-uppercase">
<Indicator color="blue" />
<StatusDot status="normal" />
<span className="text-util-colors-blue-light-blue-light-600">Running</span>
</div>
)
@ -107,7 +107,7 @@ const WorkflowAppLogList: FC<ILogs> = ({ logs, appDetail, onRefresh }) => {
if (status === 'partial-succeeded') {
return (
<div className="inline-flex items-center gap-1 system-xs-semibold-uppercase">
<Indicator color="green" />
<StatusDot status="success" />
<span className="text-util-colors-green-green-600">Partial Success</span>
</div>
)

View File

@ -1,5 +1,6 @@
import type { FileEntity } from '../types'
import { cn } from '@langgenius/dify-ui/cn'
import { ProgressCircle } from '@langgenius/dify-ui/progress'
import {
RiDeleteBinLine,
RiDownloadLine,
@ -9,11 +10,11 @@ import {
memo,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
import { PreviewMode } from '@/app/components/base/features/types'
import { ReplayLine } from '@/app/components/base/icons/src/vender/other'
import ImagePreview from '@/app/components/base/image-uploader/image-preview'
import ProgressCircle from '@/app/components/base/progress-bar/progress-circle'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import { downloadUrl } from '@/utils/download'
import { formatFileSize } from '@/utils/format'
@ -43,6 +44,7 @@ const FileInAttachmentItem = ({
canPreview,
previewMode = PreviewMode.CurrentPage,
}: FileInAttachmentItemProps) => {
const { t } = useTranslation()
const { id, name, type, progress, supportFileType, base64Url, url, isRemote } = file
const ext = getFileExtension(name, type, isRemote)
const isImageFile = supportFileType === SupportUploadFileTypes.image
@ -108,7 +110,8 @@ const FileInAttachmentItem = ({
progress >= 0 && !fileIsUploaded(file) && (
<ProgressCircle
className="mr-2.5"
percentage={progress}
value={progress}
aria-label={t('uploading', { ns: 'custom' })}
/>
)
}

View File

@ -1,5 +1,6 @@
import type { FileEntity } from '../types'
import { Button } from '@langgenius/dify-ui/button'
import { ProgressCircle } from '@langgenius/dify-ui/progress'
import {
RiCloseLine,
RiDownloadLine,
@ -8,7 +9,6 @@ import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ReplayLine } from '@/app/components/base/icons/src/vender/other'
import ImagePreview from '@/app/components/base/image-uploader/image-preview'
import ProgressCircle from '@/app/components/base/progress-bar/progress-circle'
import { downloadUrl } from '@/utils/download'
import FileImageRender from '../file-image-render'
import {
@ -65,11 +65,9 @@ const FileImageItem = ({
progress >= 0 && !fileIsUploaded(file) && (
<div className="absolute inset-0 z-10 flex items-center justify-center border-2 border-effects-image-frame bg-background-overlay-alt">
<ProgressCircle
percentage={progress}
size={12}
circleStrokeColor="stroke-components-progress-white-border"
circleFillColor="fill-transparent"
sectorFillColor="fill-components-progress-white-progress"
value={progress}
color="white"
aria-label={t('uploading', { ns: 'custom' })}
/>
</div>
)

View File

@ -1,13 +1,13 @@
import type { FileEntity } from '../types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { ProgressCircle } from '@langgenius/dify-ui/progress'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
import AudioPreview from '@/app/components/base/file-uploader/audio-preview'
import PdfPreview from '@/app/components/base/file-uploader/dynamic-pdf-preview'
import VideoPreview from '@/app/components/base/file-uploader/video-preview'
import ProgressCircle from '@/app/components/base/progress-bar/progress-circle'
import { downloadUrl } from '@/utils/download'
import { formatFileSize } from '@/utils/format'
import FileTypeIcon from '../file-type-icon'
@ -110,9 +110,9 @@ const FileItem = ({
{
progress >= 0 && !fileIsUploaded(file) && (
<ProgressCircle
percentage={progress}
size={12}
value={progress}
className="shrink-0"
aria-label={t('uploading', { ns: 'custom' })}
/>
)
}

View File

@ -1,89 +0,0 @@
import { render } from '@testing-library/react'
import ProgressCircle from '../progress-circle'
const extractLargeArcFlag = (pathData: string): string => {
const afterA = pathData.slice(pathData.indexOf('A') + 1)
const tokens = afterA.replace(/,/g, ' ').trim().split(/\s+/)
// Arc syntax: A rx ry x-axis-rotation large-arc-flag sweep-flag x y
return tokens[3]!
}
describe('ProgressCircle', () => {
describe('Render', () => {
it('renders an SVG with default props', () => {
const { container } = render(<ProgressCircle />)
const svg = container.querySelector('svg')
const circle = container.querySelector('circle')
const path = container.querySelector('path')
expect(svg)!.toBeInTheDocument()
expect(circle)!.toBeInTheDocument()
expect(path)!.toBeInTheDocument()
})
})
describe('Props', () => {
it('applies correct size and viewBox when size is provided', () => {
const size = 24
const strokeWidth = 2
const { container } = render(
<ProgressCircle size={size} circleStrokeWidth={strokeWidth} />,
)
const svg = container.querySelector('svg') as SVGElement
expect(svg)!.toHaveAttribute('width', String(size + strokeWidth))
expect(svg)!.toHaveAttribute('height', String(size + strokeWidth))
expect(svg)!.toHaveAttribute(
'viewBox',
`0 0 ${size + strokeWidth} ${size + strokeWidth}`,
)
})
it('applies custom stroke and fill classes to the circle', () => {
const { container } = render(
<ProgressCircle
circleStrokeColor="stroke-red-500"
circleFillColor="fill-red-100"
/>,
)
const circle = container.querySelector('circle')!
expect(circle!)!.toHaveClass('stroke-red-500')
expect(circle!)!.toHaveClass('fill-red-100')
})
it('applies custom sector fill color to the path', () => {
const { container } = render(
<ProgressCircle sectorFillColor="fill-blue-500" />,
)
const path = container.querySelector('path')!
expect(path!)!.toHaveClass('fill-blue-500')
})
it('uses large arc flag when percentage is greater than 50', () => {
const { container } = render(<ProgressCircle percentage={75} />)
const path = container.querySelector('path')!
const d = path.getAttribute('d') || ''
expect(d).toContain('A')
expect(extractLargeArcFlag(d)).toBe('1')
})
it('uses small arc flag when percentage is 50 or less', () => {
const { container } = render(<ProgressCircle percentage={25} />)
const path = container.querySelector('path')!
const d = path.getAttribute('d') || ''
expect(d).toContain('A')
expect(extractLargeArcFlag(d)).toBe('0')
})
it('uses small arc flag when percentage is exactly 50', () => {
const { container } = render(<ProgressCircle percentage={50} />)
const path = container.querySelector('path')!
const d = path.getAttribute('d') || ''
expect(d).toContain('A')
expect(extractLargeArcFlag(d)).toBe('0')
})
})
})

View File

@ -1,90 +0,0 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import { useState } from 'react'
import ProgressCircle from './progress-circle'
const ProgressCircleDemo = ({
initialPercentage = 42,
size = 24,
}: {
initialPercentage?: number
size?: number
}) => {
const [percentage, setPercentage] = useState(initialPercentage)
return (
<div className="flex w-full max-w-md flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
<div className="flex items-center justify-between text-xs tracking-[0.18em] text-text-tertiary uppercase">
<span>Upload progress</span>
<span className="rounded-md border border-divider-subtle bg-background-default px-2 py-1 text-[11px] text-text-secondary">
{percentage}
%
</span>
</div>
<div className="flex items-center gap-4">
<ProgressCircle percentage={percentage} size={size} className="shrink-0" />
<input
type="range"
min={0}
max={100}
step={1}
value={percentage}
onChange={event => setPercentage(Number.parseInt(event.target.value, 10))}
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-divider-subtle accent-primary-600"
/>
</div>
<div className="flex gap-3 text-xs text-text-tertiary">
<label className="flex items-center gap-1">
Size
<input
type="number"
min={12}
max={48}
value={size}
disabled
className="h-7 w-16 rounded-md border border-divider-subtle bg-background-default px-2 text-xs"
/>
</label>
</div>
<div className="rounded-lg border border-divider-subtle bg-background-default-subtle p-3 text-[11px] leading-relaxed text-text-tertiary">
ProgressCircle renders a deterministic SVG slice. Advance the slider to preview how the arc grows for upload indicators.
</div>
</div>
)
}
const meta = {
title: 'Base/Feedback/ProgressCircle',
component: ProgressCircleDemo,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Compact radial progress indicator wired to upload flows. The story provides a slider to scrub through percentages.',
},
},
},
argTypes: {
initialPercentage: {
control: { type: 'range', min: 0, max: 100, step: 1 },
},
size: {
control: { type: 'number', min: 12, max: 48, step: 2 },
},
},
args: {
initialPercentage: 42,
size: 24,
},
tags: ['autodocs'],
} satisfies Meta<typeof ProgressCircleDemo>
export default meta
type Story = StoryObj<typeof meta>
export const Playground: Story = {}
export const NearComplete: Story = {
args: {
initialPercentage: 92,
},
}

View File

@ -1,64 +0,0 @@
import { cn } from '@langgenius/dify-ui/cn'
import { memo } from 'react'
type ProgressCircleProps = {
className?: string
percentage?: number
size?: number
circleStrokeWidth?: number
circleStrokeColor?: string
circleFillColor?: string
sectorFillColor?: string
}
const ProgressCircle: React.FC<ProgressCircleProps> = ({
className,
percentage = 0,
size = 12,
circleStrokeWidth = 1,
circleStrokeColor = 'stroke-components-progress-brand-border',
circleFillColor = 'fill-components-progress-brand-bg',
sectorFillColor = 'fill-components-progress-brand-progress',
}) => {
const radius = size / 2
const center = size / 2
const angle = (percentage / 101) * 360
const radians = (angle * Math.PI) / 180
const x = center + radius * Math.cos(radians - Math.PI / 2)
const y = center + radius * Math.sin(radians - Math.PI / 2)
const largeArcFlag = percentage > 50 ? 1 : 0
const pathData = `
M ${center},${center}
L ${center},${center - radius}
A ${radius},${radius} 0 ${largeArcFlag} 1 ${x},${y}
Z
`
return (
<svg
width={size + circleStrokeWidth}
height={size + circleStrokeWidth}
viewBox={`0 0 ${size + circleStrokeWidth} ${size + circleStrokeWidth}`}
className={className}
>
<circle
className={cn(
circleFillColor,
circleStrokeColor,
)}
cx={center + circleStrokeWidth / 2}
cy={center + circleStrokeWidth / 2}
r={radius}
strokeWidth={circleStrokeWidth}
/>
<path
className={cn(sectorFillColor)}
d={pathData}
transform={`translate(${circleStrokeWidth / 2}, ${circleStrokeWidth / 2})`}
/>
</svg>
)
}
export default memo(ProgressCircle)

View File

@ -1,5 +1,6 @@
import type { FileEntity } from '../types'
import { Button } from '@langgenius/dify-ui/button'
import { ProgressCircle } from '@langgenius/dify-ui/progress'
import {
RiCloseLine,
} from '@remixicon/react'
@ -7,9 +8,9 @@ import {
memo,
useCallback,
} from 'react'
import { useTranslation } from 'react-i18next'
import FileImageRender from '@/app/components/base/file-uploader/file-image-render'
import { ReplayLine } from '@/app/components/base/icons/src/vender/other'
import ProgressCircle from '@/app/components/base/progress-bar/progress-circle'
import { fileIsUploaded } from '../utils'
type ImageItemProps = {
@ -26,6 +27,7 @@ const ImageItem = ({
onReUpload,
onPreview,
}: ImageItemProps) => {
const { t } = useTranslation()
const { id, progress, base64Url, sourceUrl } = file
const handlePreview = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
@ -69,11 +71,9 @@ const ImageItem = ({
progress >= 0 && !fileIsUploaded(file) && (
<div className="absolute inset-0 z-10 flex items-center justify-center border-2 border-effects-image-frame bg-background-overlay-alt">
<ProgressCircle
percentage={progress}
size={12}
circleStrokeColor="stroke-components-progress-white-border"
circleFillColor="fill-transparent"
sectorFillColor="fill-components-progress-white-progress"
value={progress}
color="white"
aria-label={t('uploading', { ns: 'custom' })}
/>
</div>
)

View File

@ -1,5 +1,6 @@
import type { FileEntity } from '../types'
import { Button } from '@langgenius/dify-ui/button'
import { ProgressCircle } from '@langgenius/dify-ui/progress'
import {
RiCloseLine,
} from '@remixicon/react'
@ -7,9 +8,9 @@ import {
memo,
useCallback,
} from 'react'
import { useTranslation } from 'react-i18next'
import FileImageRender from '@/app/components/base/file-uploader/file-image-render'
import { ReplayLine } from '@/app/components/base/icons/src/vender/other'
import ProgressCircle from '@/app/components/base/progress-bar/progress-circle'
import { fileIsUploaded } from '../utils'
type ImageItemProps = {
@ -26,6 +27,7 @@ const ImageItem = ({
onReUpload,
onPreview,
}: ImageItemProps) => {
const { t } = useTranslation()
const { id, progress, base64Url, sourceUrl } = file
const handlePreview = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
@ -69,11 +71,9 @@ const ImageItem = ({
progress >= 0 && !fileIsUploaded(file) && (
<div className="absolute inset-0 z-10 flex items-center justify-center border-2 border-effects-image-frame bg-background-overlay-alt">
<ProgressCircle
percentage={progress}
size={12}
circleStrokeColor="stroke-components-progress-white-border"
circleFillColor="fill-transparent"
sectorFillColor="fill-components-progress-white-progress"
value={progress}
color="white"
aria-label={t('uploading', { ns: 'custom' })}
/>
</div>
)

View File

@ -19,46 +19,45 @@ describe('useIndexStatus', () => {
expect(keys).toEqual(expect.arrayContaining(expectedKeys))
})
// Verify each status entry has the correct color
describe('colors', () => {
it('should return orange color for queuing', () => {
describe('status variants', () => {
it('should return warning status for queuing', () => {
const { result } = renderHook(() => useIndexStatus())
expect(result.current.queuing.color).toBe('orange')
expect(result.current.queuing.status).toBe('warning')
})
it('should return blue color for indexing', () => {
it('should return normal status for indexing', () => {
const { result } = renderHook(() => useIndexStatus())
expect(result.current.indexing.color).toBe('blue')
expect(result.current.indexing.status).toBe('normal')
})
it('should return orange color for paused', () => {
it('should return warning status for paused', () => {
const { result } = renderHook(() => useIndexStatus())
expect(result.current.paused.color).toBe('orange')
expect(result.current.paused.status).toBe('warning')
})
it('should return red color for error', () => {
it('should return error status for error', () => {
const { result } = renderHook(() => useIndexStatus())
expect(result.current.error.color).toBe('red')
expect(result.current.error.status).toBe('error')
})
it('should return green color for available', () => {
it('should return success status for available', () => {
const { result } = renderHook(() => useIndexStatus())
expect(result.current.available.color).toBe('green')
expect(result.current.available.status).toBe('success')
})
it('should return green color for enabled', () => {
it('should return success status for enabled', () => {
const { result } = renderHook(() => useIndexStatus())
expect(result.current.enabled.color).toBe('green')
expect(result.current.enabled.status).toBe('success')
})
it('should return gray color for disabled', () => {
it('should return disabled status for disabled', () => {
const { result } = renderHook(() => useIndexStatus())
expect(result.current.disabled.color).toBe('gray')
expect(result.current.disabled.status).toBe('disabled')
})
it('should return gray color for archived', () => {
it('should return disabled status for archived', () => {
const { result } = renderHook(() => useIndexStatus())
expect(result.current.archived.color).toBe('gray')
expect(result.current.archived.status).toBe('disabled')
})
})
@ -105,14 +104,13 @@ describe('useIndexStatus', () => {
})
})
// Verify each entry has both color and text properties
it('should return objects with color and text properties for every status', () => {
it('should return objects with status and text properties for every status', () => {
const { result } = renderHook(() => useIndexStatus())
for (const key of Object.keys(result.current) as Array<keyof typeof result.current>) {
expect(result.current[key]).toHaveProperty('color')
expect(result.current[key]).toHaveProperty('status')
expect(result.current[key]).toHaveProperty('text')
expect(typeof result.current[key].color).toBe('string')
expect(typeof result.current[key].status).toBe('string')
expect(typeof result.current[key].text).toBe('string')
}
})

View File

@ -34,14 +34,14 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
// Mock useIndexStatus hook
vi.mock('../hooks', () => ({
useIndexStatus: () => ({
queuing: { text: 'Queuing', color: 'orange' },
indexing: { text: 'Indexing', color: 'blue' },
paused: { text: 'Paused', color: 'yellow' },
error: { text: 'Error', color: 'red' },
available: { text: 'Available', color: 'green' },
enabled: { text: 'Enabled', color: 'green' },
disabled: { text: 'Disabled', color: 'gray' },
archived: { text: 'Archived', color: 'gray' },
queuing: { text: 'Queuing', status: 'warning' },
indexing: { text: 'Indexing', status: 'normal' },
paused: { text: 'Paused', status: 'warning' },
error: { text: 'Error', status: 'error' },
available: { text: 'Available', status: 'success' },
enabled: { text: 'Enabled', status: 'success' },
disabled: { text: 'Disabled', status: 'disabled' },
archived: { text: 'Archived', status: 'disabled' },
}),
}))

View File

@ -1,15 +1,16 @@
import type { StatusDotStatus } from '@langgenius/dify-ui/status-dot'
import { useTranslation } from 'react-i18next'
export const useIndexStatus = () => {
const { t } = useTranslation()
return {
queuing: { color: 'orange', text: t('list.status.queuing', { ns: 'datasetDocuments' }) }, // waiting
indexing: { color: 'blue', text: t('list.status.indexing', { ns: 'datasetDocuments' }) }, // indexing splitting parsing cleaning
paused: { color: 'orange', text: t('list.status.paused', { ns: 'datasetDocuments' }) }, // paused
error: { color: 'red', text: t('list.status.error', { ns: 'datasetDocuments' }) }, // error
available: { color: 'green', text: t('list.status.available', { ns: 'datasetDocuments' }) }, // completedarchived = falseenabled = true
enabled: { color: 'green', text: t('list.status.enabled', { ns: 'datasetDocuments' }) }, // completedarchived = falseenabled = true
disabled: { color: 'gray', text: t('list.status.disabled', { ns: 'datasetDocuments' }) }, // completedarchived = falseenabled = false
archived: { color: 'gray', text: t('list.status.archived', { ns: 'datasetDocuments' }) }, // completedarchived = true
}
queuing: { status: 'warning', text: t('list.status.queuing', { ns: 'datasetDocuments' }) },
indexing: { status: 'normal', text: t('list.status.indexing', { ns: 'datasetDocuments' }) },
paused: { status: 'warning', text: t('list.status.paused', { ns: 'datasetDocuments' }) },
error: { status: 'error', text: t('list.status.error', { ns: 'datasetDocuments' }) },
available: { status: 'success', text: t('list.status.available', { ns: 'datasetDocuments' }) },
enabled: { status: 'success', text: t('list.status.enabled', { ns: 'datasetDocuments' }) },
disabled: { status: 'disabled', text: t('list.status.disabled', { ns: 'datasetDocuments' }) },
archived: { status: 'disabled', text: t('list.status.archived', { ns: 'datasetDocuments' }) },
} satisfies Record<string, { status: StatusDotStatus, text: string }>
}

View File

@ -1,8 +1,9 @@
import type { StatusDotStatus } from '@langgenius/dify-ui/status-dot'
import type { OperationName } from '../types'
import type { ColorMap, IndicatorProps } from '@/app/components/header/indicator'
import type { CommonResponse } from '@/models/common'
import type { DocumentDisplayStatus } from '@/models/datasets'
import { cn } from '@langgenius/dify-ui/cn'
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import { Switch } from '@langgenius/dify-ui/switch'
import { toast } from '@langgenius/dify-ui/toast'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
@ -11,19 +12,17 @@ import * as React from 'react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { Infotip } from '@/app/components/base/infotip'
import Indicator from '@/app/components/header/indicator'
import { useDocumentDelete, useDocumentDisable, useDocumentEnable } from '@/service/knowledge/use-document'
import { asyncRunSafe } from '@/utils'
import s from '../style.module.css'
import { useIndexStatus } from './hooks'
const STATUS_TEXT_COLOR_MAP: ColorMap = {
green: 'text-util-colors-green-green-600',
orange: 'text-util-colors-warning-warning-600',
red: 'text-util-colors-red-red-600',
blue: 'text-util-colors-blue-light-blue-light-600',
yellow: 'text-util-colors-warning-warning-600',
gray: 'text-text-tertiary',
const STATUS_TEXT_COLOR_MAP: Record<StatusDotStatus, string> = {
success: 'text-util-colors-green-green-600',
warning: 'text-util-colors-warning-warning-600',
error: 'text-util-colors-red-red-600',
normal: 'text-util-colors-blue-light-blue-light-600',
disabled: 'text-text-tertiary',
}
type StatusItemProps = {
status: DocumentDisplayStatus
@ -43,6 +42,7 @@ const StatusItem = ({ status, reverse = false, scene = 'list', textCls = '', err
const { t } = useTranslation()
const DOC_INDEX_STATUS_MAP = useIndexStatus()
const localStatus = status.toLowerCase() as keyof typeof DOC_INDEX_STATUS_MAP
const statusItem = DOC_INDEX_STATUS_MAP[localStatus]
const { enabled = false, archived = false, id = '' } = detail || {}
const { mutateAsync: enableDocument } = useDocumentEnable()
const { mutateAsync: disableDocument } = useDocumentDisable()
@ -78,9 +78,9 @@ const StatusItem = ({ status, reverse = false, scene = 'list', textCls = '', err
}, [localStatus])
return (
<div className={cn('flex items-center', reverse ? 'flex-row-reverse' : '', scene === 'detail' ? s.statusItemDetail : '')}>
<Indicator color={DOC_INDEX_STATUS_MAP[localStatus]?.color as IndicatorProps['color']} className={reverse ? 'ml-2' : 'mr-2'} />
<span className={cn(`${STATUS_TEXT_COLOR_MAP[DOC_INDEX_STATUS_MAP[localStatus].color as keyof typeof STATUS_TEXT_COLOR_MAP]} text-sm`, textCls)}>
{DOC_INDEX_STATUS_MAP[localStatus]?.text}
<StatusDot status={statusItem.status} className={reverse ? 'ml-2' : 'mr-2'} />
<span className={cn(`${STATUS_TEXT_COLOR_MAP[statusItem.status]} text-sm`, textCls)}>
{statusItem.text}
</span>
{errorMessage && (
<Infotip

View File

@ -1,10 +1,10 @@
import { cn } from '@langgenius/dify-ui/cn'
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import { Switch } from '@langgenius/dify-ui/switch'
import { RiArrowRightUpLine, RiBookOpenLine } from '@remixicon/react'
import * as React from 'react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import Indicator from '@/app/components/header/indicator'
import { useSelector as useAppContextSelector } from '@/context/app-context'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { useDatasetApiAccessUrl } from '@/hooks/use-api-access-url'
@ -44,9 +44,9 @@ const Card = ({
<div className="p-2">
<div className="mb-1.5 flex justify-between">
<div className="flex items-center gap-1">
<Indicator
<StatusDot
className="shrink-0"
color={apiEnabled ? 'green' : 'yellow'}
status={apiEnabled ? 'success' : 'warning'}
/>
<div
className={cn(

View File

@ -1,10 +1,10 @@
import { cn } from '@langgenius/dify-ui/cn'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ApiAggregate } from '@/app/components/base/icons/src/vender/knowledge'
import Indicator from '@/app/components/header/indicator'
import Card from './card'
type ApiAccessProps = {
@ -36,9 +36,9 @@ const ApiAccess = ({
>
<ApiAggregate className="size-4 shrink-0 text-text-secondary" />
{expand && <div className="grow system-sm-medium text-text-secondary">{t('appMenus.apiAccess', { ns: 'common' })}</div>}
<Indicator
<StatusDot
className={cn('shrink-0', !expand && 'absolute -top-px -right-px')}
color={apiEnabled ? 'green' : 'yellow'}
status={apiEnabled ? 'success' : 'warning'}
/>
</div>
</button>

View File

@ -1,11 +1,11 @@
import { Button } from '@langgenius/dify-ui/button'
import { PopoverClose } from '@langgenius/dify-ui/popover'
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import { RiBookOpenLine, RiKey2Line } from '@remixicon/react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import CopyFeedback from '@/app/components/base/copy-feedback'
import { ApiAggregate } from '@/app/components/base/icons/src/vender/knowledge'
import Indicator from '@/app/components/header/indicator'
import { useDatasetApiAccessUrl } from '@/hooks/use-api-access-url'
import Link from '@/next/link'
@ -35,10 +35,10 @@ const Card = ({
</div>
</div>
<div className="flex items-center gap-x-1">
<Indicator
<StatusDot
className="shrink-0"
color={
apiBaseUrl ? 'green' : 'yellow'
status={
apiBaseUrl ? 'success' : 'warning'
}
/>
<div

View File

@ -1,10 +1,10 @@
import { cn } from '@langgenius/dify-ui/cn'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import * as React from 'react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import SecretKeyModal from '@/app/components/develop/secret-key/secret-key-modal'
import Indicator from '@/app/components/header/indicator'
import Card from './card'
type ServiceApiProps = {
@ -40,10 +40,10 @@ const ServiceApi = ({
open ? 'bg-components-button-secondary-bg-hover' : 'hover:bg-components-button-secondary-bg-hover',
)}
>
<Indicator
<StatusDot
className={cn('shrink-0')}
color={
apiBaseUrl ? 'green' : 'yellow'
status={
apiBaseUrl ? 'success' : 'warning'
}
/>
<div className="grow system-sm-medium text-text-secondary">{t('serviceApi.title', { ns: 'dataset' })}</div>

View File

@ -353,8 +353,7 @@ describe('AccountDropdown', () => {
fireEvent.click(screen.getByRole('button'))
// Assert
const indicator = screen.getByTestId('status-indicator')
expect(indicator).toHaveClass('bg-components-badge-status-light-warning-bg')
expect(document.querySelector('.bg-components-badge-status-light-warning-bg')).toBeInTheDocument()
})
it('should show green indicator when version is latest', () => {
@ -374,8 +373,7 @@ describe('AccountDropdown', () => {
fireEvent.click(screen.getByRole('button'))
// Assert
const indicator = screen.getByTestId('status-indicator')
expect(indicator).toHaveClass('bg-components-badge-status-light-success-bg')
expect(document.querySelector('.bg-components-badge-status-light-success-bg')).toBeInTheDocument()
})
})
})

View File

@ -3,6 +3,7 @@
import type { MouseEventHandler, ReactNode } from 'react'
import { Avatar } from '@langgenius/dify-ui/avatar'
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLinkItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@langgenius/dify-ui/dropdown-menu'
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -22,7 +23,6 @@ import { systemFeaturesQueryOptions } from '@/service/system-features'
import { useLogout } from '@/service/use-common'
import AccountAbout from '../account-about'
import GithubStar from '../github-star'
import Indicator from '../indicator'
import Compliance from './compliance'
import { ExternalLinkIndicator, MenuItemContent } from './menu-item-content'
import Support from './support'
@ -216,7 +216,7 @@ export default function AppSelector() {
trailing={(
<div className="flex shrink-0 items-center">
<div className="mr-2 system-xs-regular text-text-tertiary">{langGeniusVersionInfo.current_version}</div>
<Indicator color={langGeniusVersionInfo.current_version === langGeniusVersionInfo.latest_version ? 'green' : 'orange'} />
<StatusDot status={langGeniusVersionInfo.current_version === langGeniusVersionInfo.latest_version ? 'success' : 'warning'} />
</div>
)}
/>

View File

@ -2,13 +2,13 @@ import type {
DataSourceCredential,
} from './types'
import { Button } from '@langgenius/dify-ui/button'
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import {
memo,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import Indicator from '@/app/components/header/indicator'
import Operator from './operator'
type ItemProps = {
@ -76,7 +76,7 @@ const Item = ({
}
<div className="flex shrink-0 items-center">
<div className="mr-1 flex size-3 items-center justify-center">
<Indicator color="green" />
<StatusDot status="success" />
</div>
<div className="system-xs-semibold-uppercase text-util-colors-green-green-600">
connected

View File

@ -1,6 +1,6 @@
import type { Status } from './declarations'
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import { useTranslation } from 'react-i18next'
import Indicator from '../../indicator'
type OperateProps = {
isOpen: boolean
@ -71,13 +71,13 @@ const Operate = ({
status === 'fail' && (
<div className="mr-4 flex items-center">
<div className="text-xs text-[#D92D20]">{t('provider.invalidApiKey', { ns: 'common' })}</div>
<Indicator color="red" className="ml-2" />
<StatusDot status="error" className="ml-2" />
</div>
)
}
{
status === 'success' && (
<Indicator color="green" className="mr-4" />
<StatusDot status="success" className="mr-4" />
)
}
<div

View File

@ -8,8 +8,8 @@ vi.mock('@remixicon/react', () => ({
}))
// Mock Indicator
vi.mock('@/app/components/header/indicator', () => ({
default: ({ color }: { color: string }) => <div data-testid={`indicator-${color}`} />,
vi.mock('@langgenius/dify-ui/status-dot', () => ({
StatusDot: ({ status }: { status: string }) => <div data-testid={`indicator-${status}`} />,
}))
describe('ConfigModel', () => {
@ -19,7 +19,7 @@ describe('ConfigModel', () => {
expect(screen.getByText(/modelProvider.auth.authorizationError/)).toBeInTheDocument()
expect(screen.getByTestId('scales-icon')).toBeInTheDocument()
expect(screen.getByTestId('indicator-orange')).toBeInTheDocument()
expect(screen.getByTestId('indicator-warning')).toBeInTheDocument()
fireEvent.click(screen.getByText(/modelProvider.auth.authorizationError/))
expect(onClick).toHaveBeenCalled()
@ -29,7 +29,7 @@ describe('ConfigModel', () => {
render(<ConfigModel credentialRemoved />)
expect(screen.getByText(/modelProvider.auth.credentialRemoved/)).toBeInTheDocument()
expect(screen.getByTestId('indicator-red')).toBeInTheDocument()
expect(screen.getByTestId('indicator-error')).toBeInTheDocument()
})
it('should render standard config message when no flags enabled', () => {

View File

@ -10,8 +10,8 @@ vi.mock('../authorized/credential-item', () => ({
),
}))
vi.mock('@/app/components/header/indicator', () => ({
default: () => <div data-testid="indicator" />,
vi.mock('@langgenius/dify-ui/status-dot', () => ({
StatusDot: () => <div data-testid="indicator" />,
}))
vi.mock('@remixicon/react', () => ({

View File

@ -15,8 +15,8 @@ vi.mock('../authorized', () => ({
),
}))
vi.mock('@/app/components/header/indicator', () => ({
default: ({ color }: { color: string }) => <div data-testid={`indicator-${color}`} />,
vi.mock('@langgenius/dify-ui/status-dot', () => ({
StatusDot: ({ status }: { status: string }) => <div data-testid={`indicator-${status}`} />,
}))
vi.mock('@remixicon/react', () => ({
@ -58,7 +58,7 @@ describe('SwitchCredentialInLoadBalancing', () => {
)
expect(screen.getByText('Key 1'))!.toBeInTheDocument()
expect(screen.getByTestId('indicator-green'))!.toBeInTheDocument()
expect(screen.getByTestId('indicator-success'))!.toBeInTheDocument()
})
it('should render auth removed status when selected credential is not in list', () => {
@ -73,7 +73,7 @@ describe('SwitchCredentialInLoadBalancing', () => {
)
expect(screen.getByText(/modelProvider.auth.authRemoved/))!.toBeInTheDocument()
expect(screen.getByTestId('indicator-red'))!.toBeInTheDocument()
expect(screen.getByTestId('indicator-error'))!.toBeInTheDocument()
})
it('should render unavailable status when credentials list is empty', () => {
@ -156,7 +156,7 @@ describe('SwitchCredentialInLoadBalancing', () => {
/>,
)
expect(screen.getByTestId('indicator-red'))!.toBeInTheDocument()
expect(screen.getByTestId('indicator-error'))!.toBeInTheDocument()
expect(screen.getByText(/auth.credentialUnavailableInButton/))!.toBeInTheDocument()
})
@ -244,9 +244,9 @@ describe('SwitchCredentialInLoadBalancing', () => {
/>,
)
// indicator-green shown (not authRemoved, not unavailable, not empty)
// indicator-green shown (not authRemoved, not unavailable, not empty)
expect(screen.getByTestId('indicator-green'))!.toBeInTheDocument()
// indicator-success shown (not authRemoved, not unavailable, not empty)
// indicator-success shown (not authRemoved, not unavailable, not empty)
expect(screen.getByTestId('indicator-success'))!.toBeInTheDocument()
// credential_name is empty so nothing printed for name
// credential_name is empty so nothing printed for name
// credential_name is empty so nothing printed for name

View File

@ -2,8 +2,8 @@ import type { Credential } from '../../../declarations'
import { fireEvent, render, screen } from '@testing-library/react'
import CredentialItem from '../credential-item'
vi.mock('@/app/components/header/indicator', () => ({
default: () => <div data-testid="indicator" />,
vi.mock('@langgenius/dify-ui/status-dot', () => ({
StatusDot: () => <div data-testid="indicator" />,
}))
describe('CredentialItem', () => {

View File

@ -1,5 +1,6 @@
import type { Credential } from '../../declarations'
import { cn } from '@langgenius/dify-ui/cn'
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import {
memo,
@ -8,7 +9,6 @@ import {
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
import Badge from '@/app/components/base/badge'
import Indicator from '@/app/components/header/indicator'
type CredentialItemProps = {
credential: Credential
@ -71,7 +71,7 @@ const CredentialItem = ({
</div>
)
}
<Indicator className="mr-1.5 ml-2 shrink-0" />
<StatusDot className="mr-1.5 ml-2 shrink-0" />
<div
className="truncate system-md-regular text-text-secondary"
title={credential.credential_name}

View File

@ -1,12 +1,12 @@
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import {
RiEqualizer2Line,
RiScales3Line,
} from '@remixicon/react'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import Indicator from '@/app/components/header/indicator'
type ConfigModelProps = {
onClick?: () => void
@ -30,7 +30,7 @@ const ConfigModel = ({
>
<RiScales3Line className="mr-0.5 size-3" />
{t('modelProvider.auth.authorizationError', { ns: 'common' })}
<Indicator color="orange" className="absolute -top-px -right-px size-1.5" />
<StatusDot status="warning" className="absolute -top-px -right-px size-1.5" />
</div>
)
}
@ -49,7 +49,7 @@ const ConfigModel = ({
credentialRemoved && (
<>
{t('modelProvider.auth.credentialRemoved', { ns: 'common' })}
<Indicator color="red" className="ml-2" />
<StatusDot status="error" className="ml-2" />
</>
)
}

View File

@ -4,6 +4,7 @@ import {
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import {
RiAddLine,
RiArrowDownSLine,
@ -15,7 +16,6 @@ import {
} from 'react'
import { useTranslation } from 'react-i18next'
import Badge from '@/app/components/base/badge'
import Indicator from '@/app/components/header/indicator'
import CredentialItem from './authorized/credential-item'
type CredentialSelectorProps = {
@ -60,7 +60,7 @@ const CredentialSelector = ({
selectedCredential && (
<div className="flex items-center">
{
!selectedCredential.addNewCredential && <Indicator className="mr-2 ml-1 shrink-0" />
!selectedCredential.addNewCredential && <StatusDot className="mr-2 ml-1 shrink-0" />
}
<div className="truncate system-sm-regular text-components-input-text-filled" title={selectedCredential.credential_name}>{selectedCredential.credential_name}</div>
{

View File

@ -1,3 +1,4 @@
import type { StatusDotStatus } from '@langgenius/dify-ui/status-dot'
import type { Dispatch, SetStateAction } from 'react'
import type {
Credential,
@ -6,6 +7,7 @@ import type {
} from '../declarations'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { RiArrowDownSLine } from '@remixicon/react'
import {
@ -15,7 +17,6 @@ import {
import { useTranslation } from 'react-i18next'
import Badge from '@/app/components/base/badge'
import { ConfigurationMethodEnum, ModelModalModeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import Indicator from '@/app/components/header/indicator'
import Authorized from './authorized'
type SwitchCredentialInLoadBalancingProps = {
@ -49,9 +50,9 @@ const SwitchCredentialInLoadBalancing = ({
const authRemoved = selectedCredentialId && !currentCredential && !empty
const unavailable = currentCredential?.not_allowed_to_use
let color = 'green'
let color: StatusDotStatus = 'success'
if (authRemoved || unavailable)
color = 'red'
color = 'error'
const Item = (
<Button
@ -64,9 +65,9 @@ const SwitchCredentialInLoadBalancing = ({
>
{
!empty && (
<Indicator
<StatusDot
className="mr-2"
color={color as any}
status={color}
/>
)
}

View File

@ -1,4 +1,5 @@
import { Button } from '@langgenius/dify-ui/button'
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import { useTranslation } from 'react-i18next'
import { ConfigurationMethodEnum } from '../declarations'
@ -22,9 +23,7 @@ const ConfigurationButton = ({ modelProvider, handleOpenModal }: ConfigurationBu
{t('nodes.agent.notAuthorized', { ns: 'workflow' })}
</div>
<div className="flex h-[14px] w-[14px] items-center justify-center">
<div className="h-2 w-2 shrink-0 rounded-[3px] border border-components-badge-status-light-warning-border-inner
bg-components-badge-status-light-warning-bg shadow-components-badge-status-light-warning-halo"
/>
<StatusDot status="warning" />
</div>
</Button>
)

View File

@ -4,6 +4,7 @@ import { cn } from '@langgenius/dify-ui/cn'
import { ComboboxGroup, ComboboxItem, ComboboxItemIndicator } from '@langgenius/dify-ui/combobox'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { PreviewCardTrigger } from '@langgenius/dify-ui/preview-card'
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { CreditsCoin } from '@/app/components/base/icons/src/vender/line/financeAndECommerce'
@ -122,13 +123,13 @@ function PopupItem({
: credentialName
? (
<>
<span className={cn('size-1.5 shrink-0 rounded-xs border', isApiKeyActive ? 'border-components-badge-status-light-success-border-inner bg-components-badge-status-light-success-bg' : 'border-components-badge-status-light-error-border-inner bg-components-badge-status-light-error-bg')} />
<StatusDot size="small" status={isApiKeyActive ? 'success' : 'error'} />
<span className="ml-1 truncate text-text-tertiary">{credentialName}</span>
</>
)
: (
<>
<span className="size-1.5 shrink-0 rounded-xs border border-components-badge-status-light-disabled-border-inner bg-components-badge-status-light-disabled-bg" />
<StatusDot size="small" status="disabled" />
<span className="ml-1 truncate text-text-tertiary">{t('modelProvider.selector.configureRequired', { ns: 'common' })}</span>
</>
)}

View File

@ -85,8 +85,8 @@ vi.mock('../model-auth-dropdown', () => ({
),
}))
vi.mock('@/app/components/header/indicator', () => ({
default: ({ color }: { color: string }) => <div data-testid="indicator" data-color={color} />,
vi.mock('@langgenius/dify-ui/status-dot', () => ({
StatusDot: ({ status }: { status: string }) => <div data-testid="indicator" data-status={status} />,
}))
vi.mock('@/app/components/base/icons/src/vender/line/alertsAndFeedback/Warning', () => ({
@ -192,7 +192,7 @@ describe('CredentialPanel', () => {
it('should show green indicator and credential name for api-fallback (exhausted + authorized key)', () => {
mockTrialCredits.isExhausted = true
renderWithQueryClient(createProvider())
expect(screen.getByTestId('indicator')).toHaveAttribute('data-color', 'green')
expect(screen.getByTestId('indicator')).toHaveAttribute('data-status', 'success')
expect(screen.getByText('test-credential')).toBeInTheDocument()
})
@ -206,7 +206,7 @@ describe('CredentialPanel', () => {
renderWithQueryClient(createProvider({
preferred_provider_type: PreferredProviderTypeEnum.custom,
}))
expect(screen.getByTestId('indicator')).toHaveAttribute('data-color', 'green')
expect(screen.getByTestId('indicator')).toHaveAttribute('data-status', 'success')
expect(screen.getByText('test-credential')).toBeInTheDocument()
})
@ -228,7 +228,7 @@ describe('CredentialPanel', () => {
available_credentials: [{ credential_id: 'cred-1', credential_name: 'Bad Key' }],
},
}))
expect(screen.getByTestId('indicator')).toHaveAttribute('data-color', 'red')
expect(screen.getByTestId('indicator')).toHaveAttribute('data-status', 'error')
expect(screen.getByText('Bad Key')).toBeInTheDocument()
})
})

View File

@ -1,9 +1,9 @@
import type { ModelProvider } from '../declarations'
import type { CardVariant } from './use-credential-panel-state'
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import Warning from '@/app/components/base/icons/src/vender/line/alertsAndFeedback/Warning'
import Indicator from '@/app/components/header/indicator'
import ModelAuthDropdown from './model-auth-dropdown'
import SystemQuotaCard from './system-quota-card'
import { useChangeProviderPriority } from './use-change-provider-priority'
@ -38,7 +38,7 @@ const CredentialPanel = ({
<SystemQuotaCard.Label className={needsGap ? 'gap-1' : undefined}>
{isTextLabel
? <TextLabel variant={variant} />
: <StatusLabel variant={variant} credentialName={credentialName} />}
: <CredentialStatus variant={variant} credentialName={credentialName} />}
</SystemQuotaCard.Label>
<SystemQuotaCard.Actions>
<ModelAuthDropdown
@ -78,17 +78,17 @@ function TextLabel({ variant }: { variant: CardVariant }) {
)
}
function StatusLabel({ variant, credentialName }: {
function CredentialStatus({ variant, credentialName }: {
variant: CardVariant
credentialName: string | undefined
}) {
const isDestructive = isDestructiveVariant(variant)
const dotColor = isDestructive ? 'red' : 'green'
const dotColor = isDestructive ? 'error' : 'success'
const showWarning = variant === 'api-fallback'
return (
<>
<Indicator className="shrink-0" color={dotColor} />
<StatusDot className="shrink-0" status={dotColor} />
<span
className={`truncate ${isDestructive ? 'text-text-destructive' : 'text-text-secondary'}`}
title={credentialName}

View File

@ -9,6 +9,7 @@ import type {
ModelProvider,
} from '../declarations'
import { cn } from '@langgenius/dify-ui/cn'
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import { Switch } from '@langgenius/dify-ui/switch'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useCallback, useMemo } from 'react'
@ -21,7 +22,6 @@ import s from '@/app/components/custom/style.module.css'
import { AddCredentialInLoadBalancing } from '@/app/components/header/account-setting/model-provider-page/model-auth'
import { IS_CE_EDITION } from '@/config'
import { useProviderContextSelector } from '@/context/provider-context'
import Indicator from '../../../indicator'
import { ConfigurationMethodEnum } from '../declarations'
import CooldownTimer from './cooldown-timer'
@ -194,7 +194,7 @@ const ModelLoadBalancingConfigs = ({
<Tooltip>
<TooltipTrigger
render={(
<Indicator color={credential?.not_allowed_to_use ? 'gray' : 'green'} />
<StatusDot status={credential?.not_allowed_to_use ? 'disabled' : 'success'} />
)}
/>
<TooltipContent>

View File

@ -1,79 +0,0 @@
import { render, screen } from '@testing-library/react'
import Indicator from '../index'
describe('Indicator', () => {
it('should render with default props', () => {
render(<Indicator />)
const indicator = screen.getByTestId('status-indicator')
expect(indicator).toBeInTheDocument()
expect(indicator).toHaveClass(
'bg-components-badge-status-light-success-bg',
)
expect(indicator).toHaveClass(
'border-components-badge-status-light-success-border-inner',
)
expect(indicator).toHaveClass('shadow-status-indicator-green-shadow')
})
it('should render with orange color', () => {
render(<Indicator color="orange" />)
const indicator = screen.getByTestId('status-indicator')
expect(indicator).toHaveClass(
'bg-components-badge-status-light-warning-bg',
)
expect(indicator).toHaveClass(
'border-components-badge-status-light-warning-border-inner',
)
expect(indicator).toHaveClass('shadow-status-indicator-warning-shadow')
})
it('should render with red color', () => {
render(<Indicator color="red" />)
const indicator = screen.getByTestId('status-indicator')
expect(indicator).toHaveClass('bg-components-badge-status-light-error-bg')
expect(indicator).toHaveClass(
'border-components-badge-status-light-error-border-inner',
)
expect(indicator).toHaveClass('shadow-status-indicator-red-shadow')
})
it('should render with blue color', () => {
render(<Indicator color="blue" />)
const indicator = screen.getByTestId('status-indicator')
expect(indicator).toHaveClass('bg-components-badge-status-light-normal-bg')
expect(indicator).toHaveClass(
'border-components-badge-status-light-normal-border-inner',
)
expect(indicator).toHaveClass('shadow-status-indicator-blue-shadow')
})
it('should render with yellow color', () => {
render(<Indicator color="yellow" />)
const indicator = screen.getByTestId('status-indicator')
expect(indicator).toHaveClass(
'bg-components-badge-status-light-warning-bg',
)
expect(indicator).toHaveClass(
'border-components-badge-status-light-warning-border-inner',
)
expect(indicator).toHaveClass('shadow-status-indicator-warning-shadow')
})
it('should render with gray color', () => {
render(<Indicator color="gray" />)
const indicator = screen.getByTestId('status-indicator')
expect(indicator).toHaveClass(
'bg-components-badge-status-light-disabled-bg',
)
expect(indicator).toHaveClass(
'border-components-badge-status-light-disabled-border-inner',
)
expect(indicator).toHaveClass('shadow-status-indicator-gray-shadow')
})
it('should apply custom className', () => {
render(<Indicator className="custom-class" />)
const indicator = screen.getByTestId('status-indicator')
expect(indicator).toHaveClass('custom-class')
})
})

View File

@ -1,54 +0,0 @@
'use client'
import { cn } from '@langgenius/dify-ui/cn'
export type IndicatorProps = {
color?: 'green' | 'orange' | 'red' | 'blue' | 'yellow' | 'gray'
className?: string
}
export type ColorMap = {
green: string
orange: string
red: string
blue: string
yellow: string
gray: string
}
const BACKGROUND_MAP: ColorMap = {
green: 'bg-components-badge-status-light-success-bg',
orange: 'bg-components-badge-status-light-warning-bg',
red: 'bg-components-badge-status-light-error-bg',
blue: 'bg-components-badge-status-light-normal-bg',
yellow: 'bg-components-badge-status-light-warning-bg',
gray: 'bg-components-badge-status-light-disabled-bg',
}
const BORDER_MAP: ColorMap = {
green: 'border-components-badge-status-light-success-border-inner',
orange: 'border-components-badge-status-light-warning-border-inner',
red: 'border-components-badge-status-light-error-border-inner',
blue: 'border-components-badge-status-light-normal-border-inner',
yellow: 'border-components-badge-status-light-warning-border-inner',
gray: 'border-components-badge-status-light-disabled-border-inner',
}
const SHADOW_MAP: ColorMap = {
green: 'shadow-status-indicator-green-shadow',
orange: 'shadow-status-indicator-warning-shadow',
red: 'shadow-status-indicator-red-shadow',
blue: 'shadow-status-indicator-blue-shadow',
yellow: 'shadow-status-indicator-warning-shadow',
gray: 'shadow-status-indicator-gray-shadow',
}
export default function Indicator({
color = 'green',
className = '',
}: IndicatorProps) {
return (
<div
data-testid="status-indicator"
className={cn('h-2 w-2 rounded-[3px] border border-solid', BACKGROUND_MAP[color], BORDER_MAP[color], SHADOW_MAP[color], className)}
/>
)
}

View File

@ -5,6 +5,9 @@ import { useSelectedLayoutSegment } from '@/next/navigation'
import PluginsNav from '../index'
const queryErrorStatusDot = (container: HTMLElement) =>
container.querySelector('.shadow-status-indicator-red-shadow')
vi.mock('@/next/navigation', () => ({
useSelectedLayoutSegment: vi.fn(),
}))
@ -38,7 +41,7 @@ describe('PluginsNav', () => {
const svg = linkElement.querySelector('svg')
expect(svg).toBeInTheDocument()
expect(screen.queryByTestId('status-indicator')).not.toBeInTheDocument()
expect(queryErrorStatusDot(linkElement)).not.toBeInTheDocument()
})
describe('Active State', () => {
@ -70,7 +73,7 @@ describe('PluginsNav', () => {
expect(svgs.length).toBe(1)
expect(svgs[0]).toHaveClass('install-icon')
expect(screen.queryByTestId('status-indicator')).not.toBeInTheDocument()
expect(queryErrorStatusDot(container)).not.toBeInTheDocument()
})
it('renders Installing With Error state (Inactive)', () => {
@ -81,7 +84,7 @@ describe('PluginsNav', () => {
const downloadingIcon = container.querySelector('.install-icon')
expect(downloadingIcon).toBeInTheDocument()
expect(screen.getByTestId('status-indicator')).toBeInTheDocument()
expect(queryErrorStatusDot(container)).toBeInTheDocument()
})
it('renders Failed state (Inactive)', () => {
@ -93,7 +96,7 @@ describe('PluginsNav', () => {
expect(svg).toBeInTheDocument()
expect(svg).not.toHaveClass('install-icon')
expect(screen.getByTestId('status-indicator')).toBeInTheDocument()
expect(queryErrorStatusDot(container)).toBeInTheDocument()
})
it('renders Default icon when Active even if installing', () => {

View File

@ -1,9 +1,9 @@
'use client'
import { cn } from '@langgenius/dify-ui/cn'
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import { useTranslation } from 'react-i18next'
import { Group } from '@/app/components/base/icons/src/vender/other'
import Indicator from '@/app/components/header/indicator'
import { usePluginTaskStatus } from '@/app/components/plugins/plugin-page/plugin-tasks/hooks'
import Link from '@/next/link'
import { useSelectedLayoutSegment } from '@/next/navigation'
@ -37,8 +37,8 @@ const PluginsNav = ({
>
{
(isFailed || isInstallingWithError) && !activated && (
<Indicator
color="red"
<StatusDot
status="error"
className="absolute -top-px -left-px"
/>
)

View File

@ -2,8 +2,8 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import AuthorizedInDataSourceNode from '../authorized-in-data-source-node'
vi.mock('@/app/components/header/indicator', () => ({
default: ({ color }: { color: string }) => <span data-testid="indicator" data-color={color} />,
vi.mock('@langgenius/dify-ui/status-dot', () => ({
StatusDot: ({ status }: { status: string }) => <span data-testid="indicator" data-status={status} />,
}))
describe('AuthorizedInDataSourceNode', () => {
@ -19,7 +19,7 @@ describe('AuthorizedInDataSourceNode', () => {
it('renders with green indicator', () => {
render(<AuthorizedInDataSourceNode authorizationsNum={1} onJumpToDataSourcePage={mockOnJump} />)
expect(screen.getByTestId('indicator')).toHaveAttribute('data-color', 'green')
expect(screen.getByTestId('indicator')).toHaveAttribute('data-status', 'success')
})
it('renders singular text for 1 authorization', () => {

View File

@ -1,11 +1,11 @@
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import { RiEqualizer2Line } from '@remixicon/react'
import {
memo,
} from 'react'
import { useTranslation } from 'react-i18next'
import Indicator from '@/app/components/header/indicator'
type AuthorizedInDataSourceNodeProps = {
authorizationsNum: number
@ -22,9 +22,9 @@ const AuthorizedInDataSourceNode = ({
size="small"
onClick={onJumpToDataSourcePage}
>
<Indicator
<StatusDot
className="mr-1.5"
color="green"
status="success"
/>
{
authorizationsNum > 1

View File

@ -1,9 +1,11 @@
import type { StatusDotStatus } from '@langgenius/dify-ui/status-dot'
import type {
Credential,
PluginPayload,
} from './types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import { RiArrowDownSLine } from '@remixicon/react'
import {
memo,
@ -11,7 +13,6 @@ import {
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import Indicator from '@/app/components/header/indicator'
import {
Authorized,
usePluginAuth,
@ -41,7 +42,7 @@ const AuthorizedInNode = ({
let label = ''
let removed = false
let unavailable = false
let color = 'green'
let color: StatusDotStatus = 'success'
let defaultUnavailable = false
if (!credentialId) {
label = t('auth.workspaceDefault', { ns: 'plugin' })
@ -49,7 +50,7 @@ const AuthorizedInNode = ({
const defaultCredential = credentials.find(c => c.is_default)
if (defaultCredential?.not_allowed_to_use) {
color = 'gray'
color = 'disabled'
defaultUnavailable = true
}
}
@ -60,9 +61,9 @@ const AuthorizedInNode = ({
unavailable = !!credential?.not_allowed_to_use && !credential?.from_enterprise
if (removed)
color = 'red'
color = 'error'
else if (unavailable)
color = 'gray'
color = 'disabled'
}
return (
<Button
@ -73,9 +74,9 @@ const AuthorizedInNode = ({
)}
variant={(defaultUnavailable || unavailable) ? 'ghost' : 'secondary'}
>
<Indicator
<StatusDot
className="mr-1.5"
color={color as any}
status={color}
/>
{label}
{

View File

@ -238,9 +238,7 @@ describe('Authorized Component', () => {
{ wrapper: createWrapper() },
)
// The indicator should be rendered
// The indicator should be rendered
expect(container.querySelector('[data-testid="status-indicator"]'))!.toBeInTheDocument()
expect(container.querySelector('.shadow-status-indicator-gray-shadow'))!.toBeInTheDocument()
})
})

View File

@ -18,6 +18,7 @@ import {
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import { toast } from '@langgenius/dify-ui/toast'
import {
memo,
@ -26,7 +27,6 @@ import {
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import Indicator from '@/app/components/header/indicator'
import Authorize from '../authorize'
import ApiKeyModal from '../authorize/api-key-modal'
import {
@ -204,7 +204,7 @@ const Authorized = ({
mergedIsOpen && 'bg-components-button-secondary-bg-hover',
)}
>
<Indicator className="mr-2" color={unavailableCredential ? 'gray' : 'green'} />
<StatusDot className="mr-2" status={unavailableCredential ? 'disabled' : 'success'} />
{credentials.length}
&nbsp;
{

View File

@ -1,6 +1,7 @@
import type { Credential } from '../types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import {
RiCheckLine,
@ -17,7 +18,6 @@ import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
import Badge from '@/app/components/base/badge'
import Input from '@/app/components/base/input'
import Indicator from '@/app/components/header/indicator'
import { CredentialTypeEnum } from '../types'
type ItemProps = {
@ -126,9 +126,9 @@ const Item = ({
</div>
)
}
<Indicator
<StatusDot
className="mr-1.5 ml-2 shrink-0"
color={credential.not_allowed_to_use ? 'gray' : 'green'}
status={credential.not_allowed_to_use ? 'disabled' : 'success'}
/>
<div
className="truncate system-md-regular text-text-secondary"

View File

@ -1,9 +1,11 @@
import type { StatusDotStatus } from '@langgenius/dify-ui/status-dot'
import type {
Credential,
PluginPayload,
} from './types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import { RiArrowDownSLine } from '@remixicon/react'
import {
memo,
@ -11,7 +13,6 @@ import {
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import Indicator from '@/app/components/header/indicator'
import Authorize from './authorize'
import Authorized from './authorized'
import { usePluginAuth } from './hooks/use-plugin-auth'
@ -60,7 +61,7 @@ const PluginAuthInAgent = ({
let label = ''
let removed = false
let unavailable = false
let color = 'green'
let color: StatusDotStatus = 'success'
if (!credentialId) {
label = t('auth.workspaceDefault', { ns: 'plugin' })
}
@ -70,9 +71,9 @@ const PluginAuthInAgent = ({
removed = !credential
unavailable = !!credential?.not_allowed_to_use && !credential?.from_enterprise
if (removed)
color = 'red'
color = 'error'
else if (unavailable)
color = 'gray'
color = 'disabled'
}
return (
<Button
@ -82,9 +83,9 @@ const PluginAuthInAgent = ({
removed && 'text-text-destructive',
)}
>
<Indicator
<StatusDot
className="mr-2"
color={color as any}
status={color}
/>
{label}
{

View File

@ -72,8 +72,8 @@ vi.mock('@/service/use-endpoints', () => ({
}),
}))
vi.mock('@/app/components/header/indicator', () => ({
default: ({ color }: { color: string }) => <span data-testid="indicator" data-color={color} />,
vi.mock('@langgenius/dify-ui/status-dot', () => ({
StatusDot: ({ status }: { status: string }) => <span data-testid="indicator" data-status={status} />,
}))
vi.mock('@/app/components/tools/utils/to-form-schema', () => ({
@ -176,7 +176,7 @@ describe('EndpointCard', () => {
render(<EndpointCard pluginDetail={mockPluginDetail} data={mockEndpointData} handleChange={mockHandleChange} />)
expect(screen.getByText('plugin.detailPanel.serviceOk'))!.toBeInTheDocument()
expect(screen.getByTestId('indicator'))!.toHaveAttribute('data-color', 'green')
expect(screen.getByTestId('indicator'))!.toHaveAttribute('data-status', 'success')
})
it('should show disabled status when not enabled', () => {
@ -184,7 +184,7 @@ describe('EndpointCard', () => {
render(<EndpointCard pluginDetail={mockPluginDetail} data={disabledData} handleChange={mockHandleChange} />)
expect(screen.getByText('plugin.detailPanel.disabled'))!.toBeInTheDocument()
expect(screen.getByTestId('indicator'))!.toHaveAttribute('data-color', 'gray')
expect(screen.getByTestId('indicator'))!.toHaveAttribute('data-status', 'disabled')
})
})

View File

@ -1,6 +1,6 @@
// import { useAppContext } from '@/context/app-context'
// import { Button } from '@langgenius/dify-ui/button'
// import Indicator from '@/app/components/header/indicator'
// import { StatusDot } from '@langgenius/dify-ui/status-dot'
// import ToolItem from '@/app/components/tools/provider/tool-item'
// import ConfigCredential from '@/app/components/tools/setting/build-in/config-credentials'
import type { PluginDetail } from '@/app/components/plugins/types'
@ -60,7 +60,7 @@ const ActionList = ({
onClick={() => setShowSettingAuth(true)}
disabled={!isCurrentWorkspaceManager}
>
<Indicator className='mr-2' color={'green'} />
<StatusDot className='mr-2' status={'success'} />
{t('tools.auth.authorized')}
</Button>
)} */}

View File

@ -8,6 +8,7 @@ import {
AlertDialogContent,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import { Switch } from '@langgenius/dify-ui/switch'
import { toast } from '@langgenius/dify-ui/toast'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
@ -18,7 +19,6 @@ import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
import { CopyCheck } from '@/app/components/base/icons/src/vender/line/files'
import Indicator from '@/app/components/header/indicator'
import { addDefaultValue, toolCredentialToFormSchemas } from '@/app/components/tools/utils/to-form-schema'
import {
useDeleteEndpoint,
@ -199,13 +199,13 @@ const EndpointCard = ({
<div className="flex items-center justify-between p-2 pl-3">
{active && (
<div className="flex items-center gap-1 system-xs-semibold-uppercase text-util-colors-green-green-600">
<Indicator color="green" />
<StatusDot status="success" />
{t('detailPanel.serviceOk', { ns: 'plugin' })}
</div>
)}
{!active && (
<div className="flex items-center gap-1 system-xs-semibold-uppercase text-text-tertiary">
<Indicator color="gray" />
<StatusDot status="disabled" />
{t('detailPanel.disabled', { ns: 'plugin' })}
</div>
)}

View File

@ -2,6 +2,7 @@
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import { Switch } from '@langgenius/dify-ui/switch'
import {
RiDeleteBinLine,
@ -14,7 +15,6 @@ import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
import AppIcon from '@/app/components/base/app-icon'
import { Group } from '@/app/components/base/icons/src/vender/other'
import Indicator from '@/app/components/header/indicator'
import { InstallPluginButton } from '@/app/components/workflow/nodes/_base/components/install-plugin-button'
import { useMCPToolAvailability } from '@/app/components/workflow/nodes/_base/components/mcp-tool-availability'
import McpToolNotSupportTooltip from '@/app/components/workflow/nodes/_base/components/mcp-tool-not-support-tooltip'
@ -128,13 +128,13 @@ const ToolItem = ({
{!isError && !uninstalled && !versionMismatch && noAuth && (
<Button variant="secondary" size="small">
{t('notAuthorized', { ns: 'tools' })}
<Indicator className="ml-2" color="orange" />
<StatusDot className="ml-2" status="warning" />
</Button>
)}
{!isError && !uninstalled && !versionMismatch && authRemoved && (
<Button variant="secondary" size="small">
{t('auth.authRemoved', { ns: 'plugin' })}
<Indicator className="ml-2" color="red" />
<StatusDot className="ml-2" status="error" />
</Button>
)}
{!isError && !uninstalled && versionMismatch && installInfo && (

View File

@ -1,9 +1,9 @@
import { fireEvent, render, screen } from '@testing-library/react'
import TaskStatusIndicator from '../task-status-indicator'
vi.mock('@/app/components/base/progress-bar/progress-circle', () => ({
default: ({ percentage }: { percentage: number }) => (
<div data-testid="progress-circle" data-percentage={percentage} />
vi.mock('@langgenius/dify-ui/progress', () => ({
ProgressCircle: ({ value }: { value: number }) => (
<div data-testid="progress-circle" data-value={value} />
),
}))
@ -68,7 +68,7 @@ describe('TaskStatusIndicator', () => {
/>,
)
const progress = screen.getByTestId('progress-circle')
expect(progress).toHaveAttribute('data-percentage', '40')
expect(progress).toHaveAttribute('data-value', '40')
})
it('should show progress circle when isInstallingWithSuccess', () => {
@ -81,7 +81,7 @@ describe('TaskStatusIndicator', () => {
/>,
)
const progress = screen.getByTestId('progress-circle')
expect(progress).toHaveAttribute('data-percentage', '75')
expect(progress).toHaveAttribute('data-value', '75')
})
it('should show error progress circle when isInstallingWithError', () => {
@ -106,7 +106,7 @@ describe('TaskStatusIndicator', () => {
/>,
)
const progress = screen.getByTestId('progress-circle')
expect(progress).toHaveAttribute('data-percentage', '0')
expect(progress).toHaveAttribute('data-value', '0')
})
})

View File

@ -1,8 +1,8 @@
import type { FC } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { ProgressCircle } from '@langgenius/dify-ui/progress'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import ProgressCircle from '@/app/components/base/progress-bar/progress-circle'
import DownloadingIcon from '@/app/components/header/plugins-nav/downloading-icon'
type TaskStatusIndicatorProps = {
@ -67,16 +67,15 @@ const TaskStatusIndicator: FC<TaskStatusIndicatorProps> = ({
<div className="absolute -top-1 -right-1">
{(isInstalling || isInstallingWithSuccess) && (
<ProgressCircle
percentage={(totalPluginsLength > 0 ? successPluginsLength / totalPluginsLength : 0) * 100}
circleFillColor="fill-components-progress-brand-bg"
value={(totalPluginsLength > 0 ? successPluginsLength / totalPluginsLength : 0) * 100}
aria-label={tip}
/>
)}
{isInstallingWithError && (
<ProgressCircle
percentage={(totalPluginsLength > 0 ? runningPluginsLength / totalPluginsLength : 0) * 100}
circleFillColor="fill-components-progress-brand-bg"
sectorFillColor="fill-components-progress-error-border"
circleStrokeColor="stroke-components-progress-error-border"
value={(totalPluginsLength > 0 ? runningPluginsLength / totalPluginsLength : 0) * 100}
color="error"
aria-label={tip}
/>
)}
{showSuccessIcon && !isInstalling && !isInstallingWithSuccess && !isInstallingWithError && (

View File

@ -12,6 +12,7 @@ import {
} from '@langgenius/dify-ui/alert-dialog'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useBoolean } from 'ahooks'
import copy from 'copy-to-clipboard'
@ -19,7 +20,6 @@ import * as React from 'react'
import { useCallback, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
import Indicator from '@/app/components/header/indicator'
import Icon from '@/app/components/plugins/card/base/card-icon'
import { useAppContext } from '@/context/app-context'
import { openOAuthPopup } from '@/hooks/use-oauth'
@ -231,7 +231,7 @@ const MCPDetailContent: FC<Props> = ({
onClick={handleAuthorize}
disabled={!isCurrentWorkspaceManager}
>
<Indicator className="mr-2" color="green" />
<StatusDot className="mr-2" status="success" />
{t('auth.authorized', { ns: 'tools' })}
</Button>
)}

View File

@ -16,6 +16,7 @@ import {
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import { Switch } from '@langgenius/dify-ui/switch'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { RiEditLine, RiLoopLeftLine } from '@remixicon/react'
@ -24,7 +25,6 @@ import { useTranslation } from 'react-i18next'
import CopyFeedback from '@/app/components/base/copy-feedback'
import Divider from '@/app/components/base/divider'
import { Mcp } from '@/app/components/base/icons/src/vender/other'
import Indicator from '@/app/components/header/indicator'
import MCPServerModal from '@/app/components/tools/mcp/mcp-server-modal'
import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
import { useDocLink } from '@/context/i18n'
@ -40,7 +40,7 @@ const StatusIndicator: FC<StatusIndicatorProps> = ({ serverActivated }) => {
const { t } = useTranslation()
return (
<div className="flex items-center gap-1">
<Indicator color={serverActivated ? 'green' : 'yellow'} />
<StatusDot status={serverActivated ? 'success' : 'warning'} />
<div className={cn('system-xs-semibold-uppercase', serverActivated ? 'text-text-success' : 'text-text-warning')}>
{serverActivated
? t('overview.status.running', { ns: 'appOverview' })

View File

@ -9,11 +9,11 @@ import {
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { cn } from '@langgenius/dify-ui/cn'
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import { RiHammerFill } from '@remixicon/react'
import { useBoolean } from 'ahooks'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Indicator from '@/app/components/header/indicator'
import Icon from '@/app/components/plugins/card/base/card-icon'
import { useAppContext } from '@/context/app-context'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
@ -112,11 +112,11 @@ const MCPCard = ({
<div className={cn('system-xs-regular text-divider-deep', (!data.is_team_authorization || !data.tools.length) && 'sm:hidden')}>/</div>
<div className={cn('truncate system-xs-regular text-text-tertiary', (!data.is_team_authorization || !data.tools.length) && 'sm:hidden')} title={`${t('mcp.updateTime', { ns: 'tools' })} ${formatTimeFromNow(data.updated_at! * 1000)}`}>{`${t('mcp.updateTime', { ns: 'tools' })} ${formatTimeFromNow(data.updated_at! * 1000)}`}</div>
</div>
{data.is_team_authorization && data.tools.length > 0 && <Indicator color="green" className="shrink-0" />}
{data.is_team_authorization && data.tools.length > 0 && <StatusDot status="success" className="shrink-0" />}
{(!data.is_team_authorization || !data.tools.length) && (
<div className="flex shrink-0 items-center gap-1 rounded-md border border-util-colors-red-red-500 bg-components-badge-bg-red-soft px-1.5 py-0.5 system-xs-medium text-util-colors-red-red-500">
{t('mcp.noConfigured', { ns: 'tools' })}
<Indicator color="red" />
<StatusDot status="error" />
</div>
)}
</div>

View File

@ -83,8 +83,8 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
},
}))
vi.mock('@/app/components/header/indicator', () => ({
default: () => <span data-testid="indicator" />,
vi.mock('@langgenius/dify-ui/status-dot', () => ({
StatusDot: () => <span data-testid="indicator" />,
}))
vi.mock('@/app/components/plugins/card/base/card-icon', () => ({

View File

@ -20,6 +20,7 @@ import {
DrawerPortal,
DrawerViewport,
} from '@langgenius/dify-ui/drawer'
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import { toast } from '@langgenius/dify-ui/toast'
import {
RiCloseLine,
@ -31,7 +32,6 @@ import ActionButton from '@/app/components/base/action-button'
import { LinkExternal02, Settings01 } from '@/app/components/base/icons/src/vender/line/general'
import Loading from '@/app/components/base/loading'
import { ConfigurationMethodEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import Indicator from '@/app/components/header/indicator'
import Icon from '@/app/components/plugins/card/base/card-icon'
import Description from '@/app/components/plugins/card/base/description'
import OrgInfo from '@/app/components/plugins/card/base/org-info'
@ -325,7 +325,7 @@ const ProviderDetail = ({
}}
disabled={!isCurrentWorkspaceManager}
>
<Indicator className="mr-2" color="green" />
<StatusDot className="mr-2" status="success" />
{t('auth.authorized', { ns: 'tools' })}
</Button>
)}

View File

@ -1,9 +1,9 @@
'use client'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
import Indicator from '@/app/components/header/indicator'
import { useRouter } from '@/next/navigation'
import Divider from '../../base/divider'
@ -90,7 +90,7 @@ const WorkflowToolConfigureButton = ({
disabled={!isCurrentWorkspaceManager || disabled}
>
{t('common.configure', { ns: 'workflow' })}
{outdated && <Indicator className="ml-1" color="yellow" />}
{outdated && <StatusDot className="ml-1" status="warning" />}
</Button>
<Button
size="small"

View File

@ -1,8 +1,9 @@
import type { ComponentProps, PropsWithChildren, ReactNode } from 'react'
import type { StatusDotStatus } from '@langgenius/dify-ui/status-dot'
import type { PropsWithChildren, ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { memo } from 'react'
import Indicator from '@/app/components/header/indicator'
type SettingItemProps = PropsWithChildren<{
label: string
@ -11,7 +12,7 @@ type SettingItemProps = PropsWithChildren<{
}>
export const SettingItem = memo(({ label, children, status, tooltip }: SettingItemProps) => {
const indicator: ComponentProps<typeof Indicator>['color'] = status === 'error' ? 'red' : status === 'warning' ? 'yellow' : undefined
const indicator: StatusDotStatus | undefined = status === 'error' ? 'error' : status === 'warning' ? 'warning' : undefined
const needTooltip = ['error', 'warning'].includes(status as any)
return (
<div className="relative flex items-center justify-between space-x-1 rounded-md bg-workflow-block-parma-bg px-1.5 py-1 text-xs font-normal">
@ -31,7 +32,7 @@ export const SettingItem = memo(({ label, children, status, tooltip }: SettingIt
{tooltip}
</TooltipContent>
</Tooltip>
{indicator && <Indicator color={indicator} className="absolute -top-0.5 -right-0.5" />}
{indicator && <StatusDot status={indicator} className="absolute -top-0.5 -right-0.5" />}
</div>
)
})

View File

@ -51,8 +51,8 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/model-selec
),
}))
vi.mock('@/app/components/header/indicator', () => ({
default: ({ color }: any) => <div>{`indicator:${color}`}</div>,
vi.mock('@langgenius/dify-ui/status-dot', () => ({
StatusDot: ({ status }: any) => <div>{`indicator:${status}`}</div>,
}))
vi.mock('@/service/use-tools', () => ({
@ -253,15 +253,15 @@ describe('agent path', () => {
const { rerender, container } = render(<ModelBar />)
expect(container).toHaveTextContent('no-model:0')
expect(screen.getByText('indicator:red')).toBeInTheDocument()
expect(screen.getByText('indicator:error')).toBeInTheDocument()
rerender(<ModelBar provider="openai" model="gpt-4o" />)
expect(container).toHaveTextContent('openai/gpt-4o:1')
expect(screen.queryByText('indicator:red')).not.toBeInTheDocument()
expect(screen.queryByText('indicator:error')).not.toBeInTheDocument()
rerender(<ModelBar provider="openai" model="gpt-4.1" />)
expect(container).toHaveTextContent('openai/gpt-4.1:1')
expect(screen.getByText('indicator:red')).toBeInTheDocument()
expect(screen.getByText('indicator:error')).toBeInTheDocument()
})
it('should render tool icons across loading, marketplace fallback, authorization warning, and fetch-error states', async () => {
@ -276,7 +276,7 @@ describe('agent path', () => {
unmount()
const secondRender = render(<ToolIcon id="tool-1" providerName="author/tool-b" />)
expect(screen.getByText('app-icon:#fff:B')).toBeInTheDocument()
expect(screen.getByText('indicator:yellow')).toBeInTheDocument()
expect(screen.getByText('indicator:warning')).toBeInTheDocument()
mockBuiltInTools = undefined
secondRender.rerender(<ToolIcon id="tool-2" providerName="author/tool-c" />)
@ -301,7 +301,7 @@ describe('agent path', () => {
expect(screen.getByText('workflow.nodes.agent.model')).toBeInTheDocument()
expect(screen.getByText('workflow.nodes.agent.toolbox')).toBeInTheDocument()
expect(container).toHaveTextContent('openai/gpt-4o:1')
expect(screen.getByText('indicator:yellow')).toBeInTheDocument()
expect(screen.getByText('indicator:warning')).toBeInTheDocument()
})
it('should render the panel, update the selected strategy, and expose memory plus output vars', async () => {

View File

@ -31,8 +31,8 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/model-selec
),
}))
vi.mock('@/app/components/header/indicator', () => ({
default: ({ color }: { color: string }) => <div>{`indicator:${color}`}</div>,
vi.mock('@langgenius/dify-ui/status-dot', () => ({
StatusDot: ({ status }: { status: string }) => <div>{`indicator:${status}`}</div>,
}))
describe('agent/model-bar', () => {
@ -53,7 +53,7 @@ describe('agent/model-bar', () => {
const emptySelector = screen.getByText((_, element) => element?.textContent === 'no-model:0')
expect(emptySelector).toBeInTheDocument()
expect(screen.getByText('indicator:red')).toBeInTheDocument()
expect(screen.getByText('indicator:error')).toBeInTheDocument()
expect(screen.getByLabelText('workflow.nodes.agent.modelNotSelected')).toBeInTheDocument()
})
@ -61,14 +61,14 @@ describe('agent/model-bar', () => {
render(<ModelBar provider="openai" model="gpt-4o" />)
expect(screen.getByText('openai/gpt-4o:1')).toBeInTheDocument()
expect(screen.queryByText('indicator:red')).not.toBeInTheDocument()
expect(screen.queryByText('indicator:error')).not.toBeInTheDocument()
})
it('should show a warning tooltip when the selected model is not installed', () => {
render(<ModelBar provider="openai" model="gpt-4.1" />)
expect(screen.getByText('openai/gpt-4.1:1')).toBeInTheDocument()
expect(screen.getByText('indicator:red')).toBeInTheDocument()
expect(screen.getByText('indicator:error')).toBeInTheDocument()
expect(screen.getByLabelText('workflow.nodes.agent.modelNotInstallTooltip')).toBeInTheDocument()
})
})

View File

@ -37,8 +37,8 @@ vi.mock('@/app/components/base/icons/src/vender/other', () => ({
Group: ({ className }: { className?: string }) => <div className={className}>group-icon</div>,
}))
vi.mock('@/app/components/header/indicator', () => ({
default: ({ color }: { color: string }) => <div>{`indicator:${color}`}</div>,
vi.mock('@langgenius/dify-ui/status-dot', () => ({
StatusDot: ({ status }: { status: string }) => <div>{`indicator:${status}`}</div>,
}))
vi.mock('@/utils/get-icon', () => ({
@ -87,7 +87,7 @@ describe('agent/tool-icon', () => {
const { rerender } = render(<ToolIcon id="tool-2" providerName="author/tool-b" />)
expect(screen.getByText('indicator:yellow')).toBeInTheDocument()
expect(screen.getByText('indicator:warning')).toBeInTheDocument()
expect(screen.getByLabelText('workflow.nodes.agent.toolNotAuthorizedTooltip:{"tool":"tool-b"}')).toBeInTheDocument()
mockWorkflowTools = []
@ -96,7 +96,7 @@ describe('agent/tool-icon', () => {
const marketplaceIcon = screen.getByRole('img', { name: 'tool icon' })
expect(marketplaceIcon).toHaveAttribute('src', 'https://example.com/market-tool.png')
expect(screen.getByText('indicator:red')).toBeInTheDocument()
expect(screen.getByText('indicator:error')).toBeInTheDocument()
expect(screen.getByLabelText('workflow.nodes.agent.toolNotInstallTooltip:{"tool":"tool-c"}')).toBeInTheDocument()
})

View File

@ -1,11 +1,11 @@
import type { FC } from 'react'
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useModelList } from '@/app/components/header/account-setting/model-provider-page/hooks'
import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector'
import Indicator from '@/app/components/header/indicator'
type ModelBarProps = {
provider: string
@ -54,7 +54,7 @@ export const ModelBar: FC<ModelBarProps> = (props) => {
readonly
deprecatedClassName="opacity-50"
/>
<Indicator color="red" className="absolute -top-0.5 -right-0.5" />
<StatusDot status="error" className="absolute -top-0.5 -right-0.5" />
</div>
)}
/>
@ -83,7 +83,7 @@ export const ModelBar: FC<ModelBarProps> = (props) => {
readonly
deprecatedClassName="opacity-50"
/>
{showWarn && <Indicator color="red" className="absolute -top-0.5 -right-0.5" />}
{showWarn && <StatusDot status="error" className="absolute -top-0.5 -right-0.5" />}
</div>
)

View File

@ -1,11 +1,11 @@
import type { ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { memo, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import { Group } from '@/app/components/base/icons/src/vender/other'
import Indicator from '@/app/components/header/indicator'
import { useAllBuiltInTools, useAllCustomTools, useAllMCPTools, useAllWorkflowTools } from '@/service/use-tools'
import { getIconFromMarketPlace } from '@/utils/get-icon'
@ -50,7 +50,7 @@ export const ToolIcon = memo(({ providerName }: ToolIconProps) => {
return 'not-authorized'
return undefined
}, [currentProvider, isDataReady])
const indicator = status === 'not-installed' ? 'red' : status === 'not-authorized' ? 'yellow' : undefined
const indicator = status === 'not-installed' ? 'error' : status === 'not-authorized' ? 'warning' : undefined
const notSuccess = (['not-installed', 'not-authorized'] as Array<Status>).includes(status)
const { t } = useTranslation()
const tooltip = useMemo(() => {
@ -96,7 +96,7 @@ export const ToolIcon = memo(({ providerName }: ToolIconProps) => {
<div className="flex size-5 items-center justify-center overflow-hidden rounded-md border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge">
{iconContent}
</div>
{indicator && <Indicator color={indicator} className="absolute -top-px -right-px" />}
{indicator && <StatusDot status={indicator} className="absolute -top-px -right-px" />}
</div>
)

View File

@ -6,6 +6,7 @@ import type {
} from '@/app/components/workflow/types'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import { Switch } from '@langgenius/dify-ui/switch'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import {
@ -19,7 +20,6 @@ import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
import Badge from '@/app/components/base/badge/index'
import Indicator from '@/app/components/header/indicator'
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
import { DeliveryMethodType } from '../../types'
import EmailConfigureModal from './email-configure-modal'
@ -177,7 +177,7 @@ const DeliveryMethodItem: FC<DeliveryMethodItemProps> = ({
disabled={readonly}
>
{t(`${i18nPrefix}.deliveryMethod.notConfigured`, { ns: 'workflow' })}
<Indicator color="orange" className="ml-1" />
<StatusDot status="warning" className="ml-1" />
</Button>
)}
</div>

View File

@ -1,9 +1,9 @@
'use client'
import type { FC } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import { useMemo } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import Indicator from '@/app/components/header/indicator'
import StatusContainer from '@/app/components/workflow/run/status-container'
import { useDocLink } from '@/context/i18n'
import { useWorkflowPausedDetails } from '@/service/use-log'
@ -112,43 +112,43 @@ const StatusPanel: FC<ResultProps> = ({
>
{status === 'running' && (
<>
<Indicator color="blue" />
<StatusDot status="normal" />
<span>{isListening ? 'Listening' : 'Running'}</span>
</>
)}
{status === 'succeeded' && (
<>
<Indicator color="green" />
<StatusDot status="success" />
<span>SUCCESS</span>
</>
)}
{status === 'partial-succeeded' && (
<>
<Indicator color="green" />
<StatusDot status="success" />
<span>PARTIAL SUCCESS</span>
</>
)}
{status === 'exception' && (
<>
<Indicator color="yellow" />
<StatusDot status="warning" />
<span>EXCEPTION</span>
</>
)}
{status === 'failed' && (
<>
<Indicator color="red" />
<StatusDot status="error" />
<span>FAIL</span>
</>
)}
{status === 'stopped' && (
<>
<Indicator color="yellow" />
<StatusDot status="warning" />
<span>STOP</span>
</>
)}
{status === 'paused' && (
<>
<Indicator color="yellow" />
<StatusDot status="warning" />
<span>PENDING</span>
</>
)}