feat: add admin_dialog package with component and metadata validation tests; implement audit log package with filtering, formatting, and statistics functionalities

This commit is contained in:
2025-12-30 01:39:39 +00:00
parent 589f61faeb
commit 13855240ac
11 changed files with 673 additions and 244 deletions

View File

@@ -1,88 +0,0 @@
import { FilmSlate, ImageSquare } from '@phosphor-icons/react'
import Image from 'next/image'
import {
Badge,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Input,
Label,
} from '@/components/ui'
interface MediaPaneProps {
thumbnailUrl?: string
videoUrl?: string
onThumbnailChange?: (value: string) => void
onVideoChange?: (value: string) => void
}
export function MediaPane({
thumbnailUrl,
videoUrl,
onThumbnailChange,
onVideoChange,
}: MediaPaneProps) {
return (
<Card className="h-full">
<CardHeader className="space-y-1">
<CardTitle className="flex items-center gap-2">
<FilmSlate size={20} weight="duotone" />
Media
</CardTitle>
<CardDescription>
Optional visuals to make the quick guide easier to follow.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="thumbnail-url">Thumbnail image</Label>
<Input
id="thumbnail-url"
value={thumbnailUrl || ''}
onChange={e => onThumbnailChange?.(e.target.value)}
placeholder="https://images.example.com/quick-guide.png"
/>
<p className="text-xs text-muted-foreground">Shown in dashboards and previews.</p>
{thumbnailUrl && (
<div className="relative aspect-[16/9] overflow-hidden rounded-lg border bg-muted">
<Image src={thumbnailUrl} alt="Quick guide thumbnail" fill className="object-cover" />
</div>
)}
</div>
<div className="space-y-2">
<Label htmlFor="video-url">Demo video (optional)</Label>
<Input
id="video-url"
value={videoUrl || ''}
onChange={e => onVideoChange?.(e.target.value)}
placeholder="YouTube or direct MP4 link"
/>
<p className="text-xs text-muted-foreground">
Embed a short clip that shows the flow in action.
</p>
{videoUrl && (
<div className="rounded-lg border bg-black p-3 text-sm text-muted-foreground">
<Badge variant="secondary" className="mb-2 inline-flex items-center gap-1">
<ImageSquare size={14} />
Preview
</Badge>
<div className="aspect-video overflow-hidden rounded-md bg-muted">
<iframe
className="h-full w-full"
src={videoUrl}
title="Quick guide demo"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</div>
</div>
)}
</div>
</CardContent>
</Card>
)
}

View File

@@ -1,156 +0,0 @@
import { ArrowCounterClockwise, ListNumbers, Plus, Trash } from '@phosphor-icons/react'
import { useEffect, useState } from 'react'
import {
Badge,
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Input,
Label,
Textarea,
} from '@/components/ui'
export interface GuideStep {
id: string
title: string
description: string
mediaUrl?: string
duration?: string
}
interface StepsEditorProps {
steps: GuideStep[]
onChange?: (steps: GuideStep[]) => void
}
export function StepsEditor({ steps, onChange }: StepsEditorProps) {
const [localSteps, setLocalSteps] = useState<GuideStep[]>(steps)
useEffect(() => {
setLocalSteps(steps)
}, [steps])
const updateStep = (id: string, payload: Partial<GuideStep>) => {
const nextSteps = localSteps.map(step => (step.id === id ? { ...step, ...payload } : step))
setLocalSteps(nextSteps)
onChange?.(nextSteps)
}
const removeStep = (id: string) => {
const nextSteps = localSteps.filter(step => step.id !== id)
setLocalSteps(nextSteps)
onChange?.(nextSteps)
}
const addStep = () => {
const newStep: GuideStep = {
id: crypto.randomUUID(),
title: 'New step',
description: 'Describe what happens in this step.',
duration: '1-2 min',
}
const nextSteps = [...localSteps, newStep]
setLocalSteps(nextSteps)
onChange?.(nextSteps)
}
const resetOrdering = () => {
const nextSteps = localSteps.map((step, index) => ({ ...step, id: `step_${index + 1}` }))
setLocalSteps(nextSteps)
onChange?.(nextSteps)
}
return (
<Card className="h-full">
<CardHeader className="flex items-center justify-between space-y-0">
<div>
<CardTitle className="flex items-center gap-2">
<ListNumbers size={20} weight="duotone" />
Steps
</CardTitle>
<CardDescription>
Keep your quick guide instructions concise and actionable.
</CardDescription>
</div>
<div className="flex items-center gap-2">
<Button variant="secondary" size="sm" onClick={resetOrdering}>
<ArrowCounterClockwise size={16} />
Reset IDs
</Button>
<Button size="sm" onClick={addStep}>
<Plus size={16} />
Add Step
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{localSteps.length === 0 ? (
<p className="text-sm text-muted-foreground">Add your first step to get started.</p>
) : (
<div className="space-y-4">
{localSteps.map((step, index) => (
<div
key={step.id}
className="rounded-lg border border-border/80 bg-card/60 p-4 shadow-sm"
>
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Badge variant="outline">Step {index + 1}</Badge>
<span>Duration: {step.duration || 'n/a'}</span>
</div>
<Button variant="ghost" size="icon" onClick={() => removeStep(step.id)}>
<Trash size={16} />
</Button>
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`title-${step.id}`}>Title</Label>
<Input
id={`title-${step.id}`}
value={step.title}
onChange={e => updateStep(step.id, { title: e.target.value })}
placeholder="Give this step a short name"
/>
</div>
<div className="space-y-2">
<Label htmlFor={`duration-${step.id}`}>Expected duration</Label>
<Input
id={`duration-${step.id}`}
value={step.duration || ''}
onChange={e => updateStep(step.id, { duration: e.target.value })}
placeholder="e.g. 30s, 1-2 min"
/>
</div>
</div>
<div className="mt-3 space-y-2">
<Label htmlFor={`description-${step.id}`}>Description</Label>
<Textarea
id={`description-${step.id}`}
value={step.description}
onChange={e => updateStep(step.id, { description: e.target.value })}
rows={3}
placeholder="Outline the actions or context for this step"
/>
</div>
<div className="mt-3 space-y-2">
<Label htmlFor={`media-${step.id}`}>Media URL (optional)</Label>
<Input
id={`media-${step.id}`}
value={step.mediaUrl || ''}
onChange={e => updateStep(step.id, { mediaUrl: e.target.value })}
placeholder="Link to an image, GIF, or short video"
/>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,21 @@
-- Component validation tests for admin_dialog package
-- Uses lua_test framework
describe("Admin Dialog Components", function()
local components = load_cases("components.json")
it("should be a valid array", function()
expect(components).toBeType("table")
end)
it("should have valid component structure if components exist", function()
if #components > 0 then
for _, component in ipairs(components) do
expect(component.id).toBeTruthy()
expect(component.type).toBeTruthy()
expect(component.id).toBeType("string")
expect(component.type).toBeType("string")
end
end
end)
end)

View File

@@ -0,0 +1,30 @@
-- Metadata validation tests for admin_dialog package
-- Uses lua_test framework
describe("Admin Dialog Package Metadata", function()
local metadata = load_cases("metadata.json")
it("should have valid package structure", function()
expect(metadata.packageId).toBe("admin_dialog")
expect(metadata.name).toBe("Admin Dialog")
expect(metadata.version).toBeTruthy()
expect(metadata.description).toBeTruthy()
end)
it("should have correct package ID format", function()
expect(metadata.packageId).toMatch("^[a-z_]+$")
end)
it("should have semantic version", function()
expect(metadata.version).toMatch("^%d+%.%d+%.%d+$")
end)
it("should have exports defined", function()
expect(metadata.exports).toBeTruthy()
expect(metadata.exports.components).toBeType("table")
end)
it("should have dependencies array", function()
expect(metadata.dependencies).toBeType("table")
end)
end)

View File

@@ -0,0 +1,29 @@
{
"packageId": "audit_log",
"name": "Audit Log",
"version": "1.0.0",
"description": "Security audit log viewer and stats dashboard",
"icon": "static_content/icon.svg",
"author": "MetaBuilder",
"category": "admin",
"dependencies": [],
"exports": {
"components": [
"AuditLogViewer",
"AuditStatsCard",
"LogTable",
"LogFilters"
],
"scripts": [
"init",
"stats",
"filters",
"formatting"
]
},
"minLevel": 3,
"bindings": {
"dbal": true,
"browser": false
}
}

View File

@@ -0,0 +1,155 @@
-- Audit log filtering logic
local M = {}
-- Filter logs by operation type
function M.filterByOperation(logs, operation)
if not operation or operation == "" then
return logs
end
local result = {}
for _, log in ipairs(logs or {}) do
if log.operation == operation then
result[#result + 1] = log
end
end
return result
end
-- Filter logs by resource type
function M.filterByResource(logs, resource)
if not resource or resource == "" then
return logs
end
local result = {}
for _, log in ipairs(logs or {}) do
if log.resource == resource then
result[#result + 1] = log
end
end
return result
end
-- Filter logs by success status
function M.filterBySuccess(logs, success)
if success == nil then
return logs
end
local result = {}
for _, log in ipairs(logs or {}) do
if log.success == success then
result[#result + 1] = log
end
end
return result
end
-- Filter logs by username
function M.filterByUsername(logs, username)
if not username or username == "" then
return logs
end
local result = {}
local lowerUsername = string.lower(username)
for _, log in ipairs(logs or {}) do
if log.username and string.match(string.lower(log.username), lowerUsername) then
result[#result + 1] = log
end
end
return result
end
-- Filter logs by date range (timestamps in milliseconds)
function M.filterByDateRange(logs, startTime, endTime)
local result = {}
for _, log in ipairs(logs or {}) do
local ts = log.timestamp
local include = true
if startTime and ts < startTime then
include = false
end
if endTime and ts > endTime then
include = false
end
if include then
result[#result + 1] = log
end
end
return result
end
-- Apply multiple filters
function M.applyFilters(logs, filters)
filters = filters or {}
local result = logs
if filters.operation then
result = M.filterByOperation(result, filters.operation)
end
if filters.resource then
result = M.filterByResource(result, filters.resource)
end
if filters.success ~= nil then
result = M.filterBySuccess(result, filters.success)
end
if filters.username then
result = M.filterByUsername(result, filters.username)
end
if filters.startTime or filters.endTime then
result = M.filterByDateRange(result, filters.startTime, filters.endTime)
end
return result
end
-- Get unique values for filter dropdowns
function M.getFilterOptions(logs)
local operations = {}
local resources = {}
local usernames = {}
for _, log in ipairs(logs or {}) do
if log.operation then
operations[log.operation] = true
end
if log.resource then
resources[log.resource] = true
end
if log.username then
usernames[log.username] = true
end
end
local opList = {}
for op in pairs(operations) do
opList[#opList + 1] = op
end
table.sort(opList)
local resList = {}
for res in pairs(resources) do
resList[#resList + 1] = res
end
table.sort(resList)
local userList = {}
for user in pairs(usernames) do
userList[#userList + 1] = user
end
table.sort(userList)
return {
operations = opList,
resources = resList,
usernames = userList
}
end
return M

View File

@@ -0,0 +1,75 @@
-- Formatting helpers for audit log display
local M = {}
-- Operation type to color mapping
M.operationColors = {
CREATE = "bg-green-500",
READ = "bg-blue-500",
UPDATE = "bg-yellow-500",
DELETE = "bg-red-500"
}
-- Resource type to icon mapping
M.resourceIcons = {
user = "User",
credential = "ShieldCheck",
default = "ChartLine"
}
-- Get color class for operation
function M.getOperationColor(operation)
return M.operationColors[operation] or "bg-gray-500"
end
-- Get icon name for resource
function M.getResourceIcon(resource)
return M.resourceIcons[resource] or M.resourceIcons.default
end
-- Format timestamp for display
function M.formatTimestamp(timestamp)
if not timestamp then
return "Unknown"
end
-- Assuming timestamp is in milliseconds
local seconds = math.floor(timestamp / 1000)
return os.date("%Y-%m-%d %H:%M:%S", seconds)
end
-- Format a log entry for display
function M.formatLogEntry(log)
return {
id = log.id,
operation = log.operation,
operationColor = M.getOperationColor(log.operation),
resource = log.resource,
resourceId = log.resourceId,
resourceIcon = M.getResourceIcon(log.resource),
username = log.username,
timestamp = M.formatTimestamp(log.timestamp),
ipAddress = log.ipAddress,
success = log.success,
errorMessage = log.errorMessage,
rowClass = log.success and "bg-card" or "bg-destructive/5 border-destructive/20"
}
end
-- Format all logs for display
function M.formatAllLogs(logs)
local result = {}
for i, log in ipairs(logs or {}) do
result[i] = M.formatLogEntry(log)
end
return result
end
-- Get status badge text
function M.getStatusBadge(log)
if log.success then
return nil
end
return "Failed"
end
return M

View File

@@ -0,0 +1,12 @@
-- Audit log package initialization
local M = {}
M.name = "audit_log"
M.version = "1.0.0"
function M.init()
log("Audit log package initialized")
return true
end
return M

View File

@@ -0,0 +1,8 @@
{
"scripts": [
{ "file": "init.lua", "name": "init", "category": "lifecycle", "description": "Package initialization" },
{ "file": "stats.lua", "name": "stats", "category": "analytics", "description": "Statistics calculation" },
{ "file": "filters.lua", "name": "filters", "category": "data", "description": "Log filtering" },
{ "file": "formatting.lua", "name": "formatting", "category": "ui", "description": "Display formatting" }
]
}

View File

@@ -0,0 +1,118 @@
-- Statistics calculation for audit logs
local M = {}
-- Calculate stats from a list of logs
function M.calculateStats(logs)
local stats = {
total = 0,
successful = 0,
failed = 0,
rateLimit = 0
}
if not logs then
return stats
end
stats.total = #logs
for _, log in ipairs(logs) do
if log.success then
stats.successful = stats.successful + 1
else
stats.failed = stats.failed + 1
end
if log.errorMessage and string.match(log.errorMessage, "Rate limit") then
stats.rateLimit = stats.rateLimit + 1
end
end
return stats
end
-- Get stats by operation type
function M.getStatsByOperation(logs)
local byOperation = {}
if not logs then
return byOperation
end
for _, log in ipairs(logs) do
local op = log.operation or "UNKNOWN"
if not byOperation[op] then
byOperation[op] = { total = 0, successful = 0, failed = 0 }
end
byOperation[op].total = byOperation[op].total + 1
if log.success then
byOperation[op].successful = byOperation[op].successful + 1
else
byOperation[op].failed = byOperation[op].failed + 1
end
end
return byOperation
end
-- Get stats by resource type
function M.getStatsByResource(logs)
local byResource = {}
if not logs then
return byResource
end
for _, log in ipairs(logs) do
local res = log.resource or "unknown"
if not byResource[res] then
byResource[res] = { total = 0, successful = 0, failed = 0 }
end
byResource[res].total = byResource[res].total + 1
if log.success then
byResource[res].successful = byResource[res].successful + 1
else
byResource[res].failed = byResource[res].failed + 1
end
end
return byResource
end
-- Prepare stats for display
function M.prepareStatsDisplay(logs)
local stats = M.calculateStats(logs)
return {
cards = {
{
title = "Total Operations",
value = stats.total,
icon = "ChartLine",
color = "default"
},
{
title = "Successful",
value = stats.successful,
icon = "ShieldCheck",
color = "green"
},
{
title = "Failed",
value = stats.failed,
icon = "Warning",
color = "red"
},
{
title = "Rate Limited",
value = stats.rateLimit,
icon = "Clock",
color = "yellow"
}
},
byOperation = M.getStatsByOperation(logs),
byResource = M.getStatsByResource(logs)
}
end
return M

View File

@@ -0,0 +1,225 @@
[
{
"id": "steps_editor",
"type": "steps_editor",
"name": "Steps Editor",
"description": "Editor for quick guide steps with add, edit, remove, and reorder",
"props": {
"steps": []
},
"layout": {
"type": "Card",
"props": { "className": "h-full" },
"children": [
{
"type": "CardHeader",
"props": { "className": "flex items-center justify-between space-y-0" },
"children": [
{
"type": "Box",
"children": [
{
"type": "CardTitle",
"props": { "className": "flex items-center gap-2" },
"children": [
{ "type": "Icon", "props": { "name": "ListNumbers", "size": 20 } },
{ "type": "Text", "props": { "text": "Steps" } }
]
},
{
"type": "CardDescription",
"props": { "text": "Keep your quick guide instructions concise and actionable." }
}
]
},
{
"type": "Box",
"props": { "className": "flex items-center gap-2" },
"children": [
{
"type": "Button",
"props": { "variant": "secondary", "size": "sm" },
"events": { "onClick": "steps.resetOrdering" },
"children": [
{ "type": "Icon", "props": { "name": "ArrowCounterClockwise", "size": 16 } },
{ "type": "Text", "props": { "text": "Reset IDs" } }
]
},
{
"type": "Button",
"props": { "size": "sm" },
"events": { "onClick": "steps.addStep" },
"children": [
{ "type": "Icon", "props": { "name": "Plus", "size": 16 } },
{ "type": "Text", "props": { "text": "Add Step" } }
]
}
]
}
]
},
{
"type": "CardContent",
"props": { "className": "space-y-4" },
"children": [
{
"type": "List",
"props": { "dataSource": "steps", "emptyText": "Add your first step to get started." },
"itemTemplate": {
"type": "Box",
"props": { "className": "rounded-lg border border-border/80 bg-card/60 p-4 shadow-sm" },
"children": [
{
"type": "Box",
"props": { "className": "mb-3 flex items-center justify-between" },
"children": [
{
"type": "Box",
"props": { "className": "flex items-center gap-2 text-sm text-muted-foreground" },
"children": [
{ "type": "Badge", "props": { "variant": "outline", "text": "Step {{index}}" } },
{ "type": "Text", "props": { "text": "Duration: {{item.duration}}" } }
]
},
{
"type": "Button",
"props": { "variant": "ghost", "size": "icon" },
"events": { "onClick": "steps.removeStep" }
}
]
},
{
"type": "Grid",
"props": { "cols": 2, "gap": 3 },
"children": [
{
"type": "FormField",
"props": { "name": "title", "label": "Title", "value": "{{item.title}}", "placeholder": "Give this step a short name" }
},
{
"type": "FormField",
"props": { "name": "duration", "label": "Expected duration", "value": "{{item.duration}}", "placeholder": "e.g. 30s, 1-2 min" }
}
]
},
{
"type": "FormField",
"props": { "name": "description", "label": "Description", "type": "textarea", "value": "{{item.description}}", "rows": 3, "placeholder": "Outline the actions or context for this step" }
},
{
"type": "FormField",
"props": { "name": "mediaUrl", "label": "Media URL (optional)", "value": "{{item.mediaUrl}}", "placeholder": "Link to an image, GIF, or short video" }
}
]
}
}
]
}
]
},
"scripts": {
"addStep": "steps.addStep",
"removeStep": "steps.removeStep",
"updateStep": "steps.updateStep",
"resetOrdering": "steps.resetOrdering"
}
},
{
"id": "media_pane",
"type": "media_pane",
"name": "Media Pane",
"description": "Media management pane for thumbnail and video URLs",
"props": {
"thumbnailUrl": "",
"videoUrl": ""
},
"layout": {
"type": "Card",
"props": { "className": "h-full" },
"children": [
{
"type": "CardHeader",
"props": { "className": "space-y-1" },
"children": [
{
"type": "CardTitle",
"props": { "className": "flex items-center gap-2" },
"children": [
{ "type": "Icon", "props": { "name": "FilmSlate", "size": 20 } },
{ "type": "Text", "props": { "text": "Media" } }
]
},
{
"type": "CardDescription",
"props": { "text": "Optional visuals to make the quick guide easier to follow." }
}
]
},
{
"type": "CardContent",
"props": { "className": "space-y-4" },
"children": [
{
"type": "Box",
"props": { "className": "space-y-2" },
"children": [
{
"type": "FormField",
"props": { "name": "thumbnailUrl", "label": "Thumbnail image", "value": "{{thumbnailUrl}}", "placeholder": "https://images.example.com/quick-guide.png", "helperText": "Shown in dashboards and previews." }
},
{
"type": "ConditionalRender",
"props": { "condition": "{{thumbnailUrl}}" },
"children": [
{
"type": "Box",
"props": { "className": "relative aspect-[16/9] overflow-hidden rounded-lg border bg-muted" },
"children": [
{ "type": "Image", "props": { "src": "{{thumbnailUrl}}", "alt": "Quick guide thumbnail", "fill": true, "className": "object-cover" } }
]
}
]
}
]
},
{
"type": "Box",
"props": { "className": "space-y-2" },
"children": [
{
"type": "FormField",
"props": { "name": "videoUrl", "label": "Demo video (optional)", "value": "{{videoUrl}}", "placeholder": "YouTube or direct MP4 link", "helperText": "Embed a short clip that shows the flow in action." }
},
{
"type": "ConditionalRender",
"props": { "condition": "{{videoUrl}}" },
"children": [
{
"type": "Box",
"props": { "className": "rounded-lg border bg-black p-3" },
"children": [
{ "type": "Badge", "props": { "variant": "secondary", "text": "Preview" } },
{
"type": "Box",
"props": { "className": "aspect-video overflow-hidden rounded-md bg-muted mt-2" },
"children": [
{ "type": "Iframe", "props": { "src": "{{videoUrl}}", "className": "h-full w-full" } }
]
}
]
}
]
}
]
}
]
}
]
},
"scripts": {
"init": "media.prepareMediaState",
"onThumbnailChange": "media.handleThumbnailChange",
"onVideoChange": "media.handleVideoChange"
}
}
]