Demo
This commit is contained in:
1 parent
3f0dd9a7f2
commit
8086a9142f
6 files changed
+535
-220
No files matched your search
Executable
+47
@@ -0,0 +1,47 @@
|
||||
#!/bin/zsh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
print -u2 "Usage: Scripts/run-screenshot-demo.sh <hosts|workspace|agent|command> <en|zh>"
|
||||
}
|
||||
|
||||
if (( $# != 2 )); then
|
||||
usage
|
||||
exit 64
|
||||
fi
|
||||
|
||||
scene="$1"
|
||||
language="$2"
|
||||
|
||||
case "$scene" in
|
||||
hosts|workspace|agent|command) ;;
|
||||
*)
|
||||
print -u2 "Unknown Demo scene: $scene"
|
||||
usage
|
||||
exit 64
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$language" in
|
||||
en|zh) ;;
|
||||
*)
|
||||
print -u2 "Unknown Demo language: $language"
|
||||
usage
|
||||
exit 64
|
||||
;;
|
||||
esac
|
||||
|
||||
script_directory="${0:A:h}"
|
||||
repository_directory="${script_directory:h}"
|
||||
derived_data="$repository_directory/.build/screenshot-demo"
|
||||
application="$derived_data/Build/Products/Debug/Termind.app"
|
||||
|
||||
xcodebuild \
|
||||
-project "$repository_directory/Termind.xcodeproj" \
|
||||
-scheme Termind \
|
||||
-configuration Debug \
|
||||
-derivedDataPath "$derived_data" \
|
||||
build
|
||||
|
||||
open -n "$application" --args "--termind-demo=$scene:$language"
|
||||
+237
-161
@@ -1,175 +1,251 @@
|
||||
import Foundation
|
||||
#if DEBUG
|
||||
import Foundation
|
||||
|
||||
/// Explicit, isolated launch scenes used to capture product images for the
|
||||
/// website. Nothing here is reachable in an ordinary launch.
|
||||
enum ScreenshotDemoMode: String {
|
||||
case workspace
|
||||
case agent
|
||||
case command
|
||||
|
||||
static var active: ScreenshotDemoMode? {
|
||||
ProcessInfo.processInfo.environment["TERMIND_SCREENSHOT_DEMO"]
|
||||
.flatMap(ScreenshotDemoMode.init(rawValue:))
|
||||
}
|
||||
|
||||
static var language: AppLanguage? {
|
||||
switch ProcessInfo.processInfo.environment["TERMIND_SCREENSHOT_LANGUAGE"] {
|
||||
case "en": .english
|
||||
case "zh": .simplifiedChineseChina
|
||||
default: nil
|
||||
}
|
||||
}
|
||||
|
||||
var windowSize: CGSize {
|
||||
switch self {
|
||||
case .command:
|
||||
CGSize(width: 1_022, height: 640)
|
||||
case .workspace, .agent:
|
||||
CGSize(
|
||||
width: Metrics.Window.defaultWidth,
|
||||
height: Metrics.Window.defaultHeight
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func makeWindow(hosts: HostLibraryModel) -> WindowModel {
|
||||
switch self {
|
||||
case .workspace:
|
||||
return workspaceWindow(hosts: hosts, showsCommandResult: false)
|
||||
case .command:
|
||||
return workspaceWindow(hosts: hosts, showsCommandResult: true)
|
||||
case .agent:
|
||||
return agentWindow(hosts: hosts)
|
||||
}
|
||||
}
|
||||
|
||||
private func agentWindow(hosts: HostLibraryModel) -> WindowModel {
|
||||
guard let host = hosts.hosts.first else {
|
||||
preconditionFailure("Screenshot demo requires its bundled host")
|
||||
/// An explicit, isolated launch configuration used to capture product
|
||||
/// images. The scene and language travel as one value so a capture cannot
|
||||
/// accidentally combine demo content in one language with UI in another.
|
||||
struct ScreenshotDemoConfiguration: Equatable {
|
||||
enum Scene: String, CaseIterable {
|
||||
case hosts
|
||||
case workspace
|
||||
case agent
|
||||
case command
|
||||
}
|
||||
|
||||
let session = SSHSessionModel.demo(host: host)
|
||||
let pane = Pane(.sshSession(session))
|
||||
pane.customTitle = "nginx diagnostics"
|
||||
let workspace = Workspace(pane: pane)
|
||||
workspace.customTitle = "Production diagnostics"
|
||||
workspace.focusedPaneID = pane.id
|
||||
enum ParseError: Error, Equatable, CustomStringConvertible {
|
||||
case duplicateArguments
|
||||
case invalidFormat(String)
|
||||
case unknownScene(String)
|
||||
case unknownLanguage(String)
|
||||
|
||||
let conversation = ConversationModel.demo()
|
||||
let library = AssistantLibraryModel()
|
||||
library.insertAndSelect(conversation)
|
||||
|
||||
let window = WindowModel(workspaces: [workspace], assistantLibrary: library)
|
||||
window.showsSidebar = true
|
||||
window.sidebarMode = .assistant
|
||||
window.openAssistantInSidebar(conversation.id)
|
||||
return window
|
||||
}
|
||||
|
||||
private func workspaceWindow(
|
||||
hosts: HostLibraryModel, showsCommandResult: Bool
|
||||
) -> WindowModel {
|
||||
let saved = hosts.hosts
|
||||
precondition(saved.count >= 2, "Screenshot demo requires its bundled hosts")
|
||||
|
||||
let firstSession: SSHSessionModel
|
||||
if showsCommandResult {
|
||||
firstSession = SSHSessionModel.demo(
|
||||
host: saved[0],
|
||||
transcript: [
|
||||
"\u{001B}]7;file://web-01/var/log/nginx\u{0007}",
|
||||
"\u{001B}[2mLast login: Thu Aug 27 16:42:18 from 10.0.0.12\u{001B}[0m",
|
||||
"\u{001B}[36mdeploy@web-01\u{001B}[0m "
|
||||
+ "\u{001B}[34m/var/log/nginx\u{001B}[0m % "
|
||||
+ "find . -type f -newermt today -printf '%s %p\\n' | sort -nr | head -10 ",
|
||||
].joined(separator: "\r\n")
|
||||
)
|
||||
} else {
|
||||
firstSession = .demo(
|
||||
host: saved[0],
|
||||
transcript: broadcastTranscript(for: saved[0].name, variant: .primary)
|
||||
)
|
||||
var description: String {
|
||||
switch self {
|
||||
case .duplicateArguments:
|
||||
return "Pass --termind-demo only once."
|
||||
case .invalidFormat(let value):
|
||||
return "Invalid Demo argument \(value). Expected "
|
||||
+ "--termind-demo=<hosts|workspace|agent|command>:<en|zh>."
|
||||
case .unknownScene(let value):
|
||||
return "Unknown Demo scene \(value). Expected hosts, workspace, agent, or command."
|
||||
case .unknownLanguage(let value):
|
||||
return "Unknown Demo language \(value). Expected en or zh."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let first = Pane(.sshSession(firstSession))
|
||||
first.customTitle = showsCommandResult ? "nginx logs" : "api-01"
|
||||
let sftpSession = SFTPSessionModel.demo(hostID: saved[0].id)
|
||||
sftpSession.selection = ["/etc/nginx/nginx.conf"]
|
||||
let sftp = Pane(.sftpSession(sftpSession))
|
||||
sftp.customTitle = "nginx config"
|
||||
let secondSession = showsCommandResult
|
||||
? SSHSessionModel.demo(host: saved[1])
|
||||
: SSHSessionModel.demo(
|
||||
host: saved[1],
|
||||
transcript: broadcastTranscript(for: saved[1].name, variant: .secondary)
|
||||
)
|
||||
let second = Pane(.sshSession(secondSession))
|
||||
second.customTitle = "worker-02"
|
||||
private static let option = "--termind-demo"
|
||||
|
||||
let workspace = Workspace(pane: first)
|
||||
if showsCommandResult {
|
||||
workspace.layout = .leaf(first)
|
||||
} else {
|
||||
workspace.layout = .split(
|
||||
.init(
|
||||
axis: .horizontal,
|
||||
fraction: 0.5,
|
||||
first: .leaf(first),
|
||||
second: .split(
|
||||
.init(
|
||||
axis: .vertical,
|
||||
fraction: 0.5,
|
||||
first: .leaf(sftp),
|
||||
second: .leaf(second)
|
||||
let scene: Scene
|
||||
let language: AppLanguage
|
||||
|
||||
static var active: Self? {
|
||||
do {
|
||||
return try parse(arguments: ProcessInfo.processInfo.arguments)
|
||||
} catch {
|
||||
fatalError("Unable to launch Termind Demo: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
static func parse(arguments: [String]) throws -> Self? {
|
||||
let candidates = arguments.filter {
|
||||
$0 == option || $0.hasPrefix("\(option)=")
|
||||
}
|
||||
guard candidates.count <= 1 else { throw ParseError.duplicateArguments }
|
||||
guard let argument = candidates.first else { return nil }
|
||||
|
||||
let prefix = "\(option)="
|
||||
guard argument.hasPrefix(prefix) else {
|
||||
throw ParseError.invalidFormat(argument)
|
||||
}
|
||||
let value = String(argument.dropFirst(prefix.count))
|
||||
let components = value.split(separator: ":", omittingEmptySubsequences: false)
|
||||
guard components.count == 2 else {
|
||||
throw ParseError.invalidFormat(argument)
|
||||
}
|
||||
|
||||
let sceneValue = String(components[0])
|
||||
guard let scene = Scene(rawValue: sceneValue) else {
|
||||
throw ParseError.unknownScene(sceneValue)
|
||||
}
|
||||
|
||||
let languageValue = String(components[1])
|
||||
let language: AppLanguage
|
||||
switch languageValue {
|
||||
case "en": language = .english
|
||||
case "zh": language = .simplifiedChineseChina
|
||||
default: throw ParseError.unknownLanguage(languageValue)
|
||||
}
|
||||
return Self(scene: scene, language: language)
|
||||
}
|
||||
|
||||
var windowSize: CGSize {
|
||||
switch scene {
|
||||
case .command:
|
||||
CGSize(width: 1_022, height: 640)
|
||||
case .workspace, .agent, .hosts:
|
||||
CGSize(
|
||||
width: Metrics.Window.defaultWidth,
|
||||
height: Metrics.Window.defaultHeight
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func makeWindow(hosts: HostLibraryModel) -> WindowModel {
|
||||
switch scene {
|
||||
case .workspace:
|
||||
return workspaceWindow(hosts: hosts, showsCommandResult: false)
|
||||
case .command:
|
||||
return workspaceWindow(hosts: hosts, showsCommandResult: true)
|
||||
case .agent:
|
||||
return agentWindow(hosts: hosts)
|
||||
case .hosts:
|
||||
return hostsWindow()
|
||||
}
|
||||
}
|
||||
|
||||
private func hostsWindow() -> WindowModel {
|
||||
let window = WindowModel(vaultDestination: .hosts)
|
||||
window.showsSidebar = true
|
||||
window.sidebarMode = .navigation
|
||||
return window
|
||||
}
|
||||
|
||||
private func agentWindow(hosts: HostLibraryModel) -> WindowModel {
|
||||
guard let host = hosts.hosts.first else {
|
||||
preconditionFailure("Screenshot demo requires its bundled host")
|
||||
}
|
||||
|
||||
let session = SSHSessionModel.demo(host: host)
|
||||
let pane = Pane(.sshSession(session))
|
||||
pane.customTitle = text(en: "nginx diagnostics", zh: "Nginx 诊断")
|
||||
let workspace = Workspace(pane: pane)
|
||||
workspace.customTitle = text(en: "Production diagnostics", zh: "生产环境诊断")
|
||||
workspace.focusedPaneID = pane.id
|
||||
|
||||
let conversation = ConversationModel.demo(language: language)
|
||||
let library = AssistantLibraryModel()
|
||||
library.insertAndSelect(conversation)
|
||||
|
||||
let window = WindowModel(workspaces: [workspace], assistantLibrary: library)
|
||||
window.showsSidebar = true
|
||||
window.sidebarMode = .assistant
|
||||
window.openAssistantInSidebar(conversation.id)
|
||||
return window
|
||||
}
|
||||
|
||||
private func workspaceWindow(
|
||||
hosts: HostLibraryModel, showsCommandResult: Bool
|
||||
) -> WindowModel {
|
||||
let saved = hosts.hosts
|
||||
precondition(saved.count >= 2, "Screenshot demo requires its bundled hosts")
|
||||
|
||||
let firstSession: SSHSessionModel
|
||||
if showsCommandResult {
|
||||
firstSession = SSHSessionModel.demo(
|
||||
host: saved[0],
|
||||
transcript: [
|
||||
"\u{001B}]7;file://web-01/var/log/nginx\u{0007}",
|
||||
"\u{001B}[2mLast login: Thu Aug 27 16:42:18 from 10.0.0.12\u{001B}[0m",
|
||||
"\u{001B}[36mdeploy@web-01\u{001B}[0m "
|
||||
+ "\u{001B}[34m/var/log/nginx\u{001B}[0m % "
|
||||
+ "find . -type f -newermt today -printf '%s %p\\n' | sort -nr | head -10 ",
|
||||
].joined(separator: "\r\n")
|
||||
)
|
||||
} else {
|
||||
firstSession = .demo(
|
||||
host: saved[0],
|
||||
transcript: broadcastTranscript(for: saved[0].name, variant: .primary)
|
||||
)
|
||||
}
|
||||
|
||||
let first = Pane(.sshSession(firstSession))
|
||||
first.customTitle = showsCommandResult
|
||||
? text(en: "nginx logs", zh: "Nginx 日志")
|
||||
: "api-01"
|
||||
let sftpSession = SFTPSessionModel.demo(hostID: saved[0].id)
|
||||
sftpSession.selection = ["/etc/nginx/nginx.conf"]
|
||||
let sftp = Pane(.sftpSession(sftpSession))
|
||||
sftp.customTitle = text(en: "nginx config", zh: "Nginx 配置")
|
||||
let secondSession = showsCommandResult
|
||||
? SSHSessionModel.demo(host: saved[1])
|
||||
: SSHSessionModel.demo(
|
||||
host: saved[1],
|
||||
transcript: broadcastTranscript(for: saved[1].name, variant: .secondary)
|
||||
)
|
||||
let second = Pane(.sshSession(secondSession))
|
||||
second.customTitle = "worker-02"
|
||||
|
||||
let workspace = Workspace(pane: first)
|
||||
if showsCommandResult {
|
||||
workspace.layout = .leaf(first)
|
||||
} else {
|
||||
workspace.layout = .split(
|
||||
.init(
|
||||
axis: .horizontal,
|
||||
fraction: 0.5,
|
||||
first: .leaf(first),
|
||||
second: .split(
|
||||
.init(
|
||||
axis: .vertical,
|
||||
fraction: 0.5,
|
||||
first: .leaf(sftp),
|
||||
second: .leaf(second)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
workspace.focusedPaneID = first.id
|
||||
workspace.customTitle = showsCommandResult ? "Log investigation" : "Production diagnostics"
|
||||
workspace.broadcastsInput = !showsCommandResult
|
||||
if showsCommandResult {
|
||||
workspace.commandGeneratorState = .inserted(targetCount: 1)
|
||||
}
|
||||
workspace.focusedPaneID = first.id
|
||||
workspace.customTitle = showsCommandResult
|
||||
? text(en: "Log investigation", zh: "日志调查")
|
||||
: text(en: "Production diagnostics", zh: "生产环境诊断")
|
||||
workspace.broadcastsInput = !showsCommandResult
|
||||
if showsCommandResult {
|
||||
workspace.commandGeneratorState = .inserted(targetCount: 1)
|
||||
}
|
||||
|
||||
let window = WindowModel(workspaces: [workspace])
|
||||
window.showsSidebar = showsCommandResult
|
||||
if showsCommandResult {
|
||||
window.sidebarMode = .navigation
|
||||
}
|
||||
return window
|
||||
}
|
||||
|
||||
let window = WindowModel(workspaces: [workspace])
|
||||
window.showsSidebar = showsCommandResult
|
||||
if showsCommandResult {
|
||||
window.sidebarMode = .navigation
|
||||
private enum BroadcastTranscriptVariant { case primary, secondary }
|
||||
|
||||
private func broadcastTranscript(
|
||||
for host: String, variant: BroadcastTranscriptVariant
|
||||
) -> String {
|
||||
let prompt =
|
||||
"\u{001B}[36mdeploy@\(host)\u{001B}[0m "
|
||||
+ "\u{001B}[34m/var/log/nginx\u{001B}[0m %"
|
||||
let history: [String]
|
||||
switch variant {
|
||||
case .primary:
|
||||
history = [
|
||||
prompt + " systemctl status nginx --no-pager",
|
||||
"● nginx.service - A high performance web server",
|
||||
" Loaded: loaded (/usr/lib/systemd/system/nginx.service; enabled)",
|
||||
" Active: \u{001B}[32mactive (running)\u{001B}[0m since Thu 2026-08-27 09:14:03 UTC",
|
||||
]
|
||||
case .secondary:
|
||||
history = [
|
||||
prompt + " journalctl -u nginx -n 2 --no-pager",
|
||||
"Aug 27 09:13:58 web-02 nginx[1842]: configuration test is successful",
|
||||
"Aug 27 09:14:03 web-02 systemd[1]: Started nginx.service",
|
||||
]
|
||||
}
|
||||
return ([
|
||||
"\u{001B}]7;file://\(host)/var/log/nginx\u{0007}",
|
||||
"\u{001B}[2mLast login: Thu Aug 27 16:42:18 from 10.0.0.12\u{001B}[0m",
|
||||
] + history + [
|
||||
prompt + " tail -f /var/log/nginx/access.log",
|
||||
]).joined(separator: "\r\n")
|
||||
}
|
||||
|
||||
private func text(en: String, zh: String) -> String {
|
||||
language == .simplifiedChineseChina ? zh : en
|
||||
}
|
||||
return window
|
||||
}
|
||||
|
||||
private enum BroadcastTranscriptVariant { case primary, secondary }
|
||||
|
||||
private func broadcastTranscript(
|
||||
for host: String, variant: BroadcastTranscriptVariant
|
||||
) -> String {
|
||||
let prompt =
|
||||
"\u{001B}[36mdeploy@\(host)\u{001B}[0m "
|
||||
+ "\u{001B}[34m/var/log/nginx\u{001B}[0m %"
|
||||
let history: [String]
|
||||
switch variant {
|
||||
case .primary:
|
||||
history = [
|
||||
prompt + " systemctl status nginx --no-pager",
|
||||
"● nginx.service - A high performance web server",
|
||||
" Loaded: loaded (/usr/lib/systemd/system/nginx.service; enabled)",
|
||||
" Active: \u{001B}[32mactive (running)\u{001B}[0m since Thu 2026-08-27 09:14:03 UTC",
|
||||
]
|
||||
case .secondary:
|
||||
history = [
|
||||
prompt + " journalctl -u nginx -n 2 --no-pager",
|
||||
"Aug 27 09:13:58 web-02 nginx[1842]: configuration test is successful",
|
||||
"Aug 27 09:14:03 web-02 systemd[1]: Started nginx.service",
|
||||
]
|
||||
}
|
||||
return ([
|
||||
"\u{001B}]7;file://\(host)/var/log/nginx\u{0007}",
|
||||
"\u{001B}[2mLast login: Thu Aug 27 16:42:18 from 10.0.0.12\u{001B}[0m",
|
||||
] + history + [
|
||||
prompt + " tail -f /var/log/nginx/access.log",
|
||||
]).joined(separator: "\r\n")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -17,14 +17,23 @@ struct TermindApp: App {
|
||||
init() {
|
||||
ContextAttachmentOpener.cleanPreviousSession()
|
||||
ScrubberWebClient.setup()
|
||||
let screenshotDemo = ScreenshotDemoMode.active
|
||||
launchLanguage = ScreenshotDemoMode.language ?? AppLanguage.stored()
|
||||
#if DEBUG
|
||||
let screenshotDemo = ScreenshotDemoConfiguration.active
|
||||
launchLanguage = screenshotDemo?.language ?? AppLanguage.stored()
|
||||
#else
|
||||
launchLanguage = AppLanguage.stored()
|
||||
#endif
|
||||
do {
|
||||
let isRunningTests = ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"]
|
||||
!= nil
|
||||
#if DEBUG
|
||||
let usesInMemoryStore = isRunningTests || screenshotDemo != nil
|
||||
#else
|
||||
let usesInMemoryStore = isRunningTests
|
||||
#endif
|
||||
let configuration = ModelConfiguration(
|
||||
"Termind", schema: HostPersistence.schema,
|
||||
isStoredInMemoryOnly: isRunningTests || screenshotDemo != nil,
|
||||
isStoredInMemoryOnly: usesInMemoryStore,
|
||||
cloudKitDatabase: .none
|
||||
)
|
||||
let container = try ModelContainer(
|
||||
@@ -33,16 +42,18 @@ struct TermindApp: App {
|
||||
configurations: [configuration]
|
||||
)
|
||||
modelContainer = container
|
||||
if let screenshotDemo {
|
||||
let hosts = HostLibraryModel.demo()
|
||||
let app = AppModel(
|
||||
hosts: hosts, ai: .demo(), proAccess: .unrestrictedForTesting(),
|
||||
onboarding: OnboardingController(automaticPresentationEnabled: false)
|
||||
)
|
||||
_app = State(initialValue: app)
|
||||
launchWindow = screenshotDemo.makeWindow(hosts: hosts)
|
||||
return
|
||||
}
|
||||
#if DEBUG
|
||||
if let screenshotDemo {
|
||||
let hosts = HostLibraryModel.demo(language: screenshotDemo.language)
|
||||
let app = AppModel(
|
||||
hosts: hosts, ai: .demo(), proAccess: .unrestrictedForTesting(),
|
||||
onboarding: OnboardingController(automaticPresentationEnabled: false)
|
||||
)
|
||||
_app = State(initialValue: app)
|
||||
launchWindow = screenshotDemo.makeWindow(hosts: hosts)
|
||||
return
|
||||
}
|
||||
#endif
|
||||
let secrets = KeychainSecretStore()
|
||||
LegacyWebSearchSettings.remove { reference in
|
||||
try secrets.setString(nil, for: reference, storage: .thisDevice)
|
||||
@@ -169,21 +180,23 @@ struct TermindApp: App {
|
||||
// frame from macOS state restoration. Each demo owns its capture
|
||||
// size so tightly focused scenes do not inherit the larger
|
||||
// workspace composition.
|
||||
if let screenshotDemo = ScreenshotDemoMode.active {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.75) {
|
||||
guard let window = NSApp.windows.first(where: \.isVisible) else { return }
|
||||
window.isRestorable = false
|
||||
window.contentMinSize = .zero
|
||||
window.setFrame(
|
||||
NSRect(
|
||||
origin: window.frame.origin,
|
||||
size: screenshotDemo.windowSize
|
||||
),
|
||||
display: true
|
||||
)
|
||||
window.center()
|
||||
#if DEBUG
|
||||
if let screenshotDemo = ScreenshotDemoConfiguration.active {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.75) {
|
||||
guard let window = NSApp.windows.first(where: \.isVisible) else { return }
|
||||
window.isRestorable = false
|
||||
window.contentMinSize = .zero
|
||||
window.setFrame(
|
||||
NSRect(
|
||||
origin: window.frame.origin,
|
||||
size: screenshotDemo.windowSize
|
||||
),
|
||||
display: true
|
||||
)
|
||||
window.center()
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
||||
|
||||
@@ -5,21 +5,33 @@ import Foundation
|
||||
extension HostLibraryModel {
|
||||
/// Seed data for P0, so the sidebar and workspace list can be exercised before any
|
||||
/// persistence or networking exists.
|
||||
static func demo() -> HostLibraryModel {
|
||||
let production = HostGroup(name: "Production", sortIndex: 0)
|
||||
let staging = HostGroup(name: "Staging", sortIndex: 1)
|
||||
let homelab = HostGroup(name: "Homelab", sortIndex: 2)
|
||||
let edge = HostGroup(name: "Edge", parentID: production.id, sortIndex: 0)
|
||||
static func demo(language: AppLanguage = .english) -> HostLibraryModel {
|
||||
func text(_ en: String, _ zh: String) -> String {
|
||||
language == .simplifiedChineseChina ? zh : en
|
||||
}
|
||||
func distribution(_ id: String) -> DetectedDistribution {
|
||||
guard let distribution = DetectedDistribution(id: id) else {
|
||||
preconditionFailure("Invalid bundled Demo distribution: \(id)")
|
||||
}
|
||||
return distribution
|
||||
}
|
||||
|
||||
let production = HostGroup(name: text("Production", "生产环境"), sortIndex: 0)
|
||||
let staging = HostGroup(name: text("Staging", "预发布环境"), sortIndex: 1)
|
||||
let homelab = HostGroup(name: text("Homelab", "家庭实验室"), sortIndex: 2)
|
||||
let edge = HostGroup(
|
||||
name: text("Edge", "边缘节点"), parentID: production.id, sortIndex: 0
|
||||
)
|
||||
|
||||
let now = Date(timeIntervalSinceReferenceDate: 776_000_000)
|
||||
|
||||
let personalKey = SSHKeyRef(
|
||||
label: "Personal", algorithm: .ed25519,
|
||||
label: text("Personal", "个人"), algorithm: .ed25519,
|
||||
fingerprint: "SHA256:9pKQ2vC0nJ7hR4mXbL1sT8yWfE3dA6uZgN5qOiVxHkM",
|
||||
credentialRef: "termind.key.personal", colorTag: .blue
|
||||
)
|
||||
let workKey = SSHKeyRef(
|
||||
label: "Work (no sync)", algorithm: .ecdsa256,
|
||||
label: text("Work (no sync)", "工作(不同步)"), algorithm: .ecdsa256,
|
||||
fingerprint: "SHA256:3tBn8xLpQ2wZ7yKvR1mHcE9dS4fJ6uA0gT5iNqOxVbY",
|
||||
credentialRef: "termind.key.work",
|
||||
storage: .thisDevice, colorTag: .purple
|
||||
@@ -29,23 +41,24 @@ extension HostLibraryModel {
|
||||
// One of each shape, so all four combinations of the two secrets are on
|
||||
// screen at P0: key only, key *and* password, password only, neither.
|
||||
let deploy = Identity(
|
||||
name: "Deploy", username: "deploy", keyID: personalKey.id,
|
||||
notes: "Production web tier", lastUsedAt: now, colorTag: .green
|
||||
name: text("Deploy", "部署账号"), username: "deploy", keyID: personalKey.id,
|
||||
notes: text("Production web tier", "生产环境 Web 层"),
|
||||
lastUsedAt: now, colorTag: .green
|
||||
)
|
||||
let postgres = Identity(
|
||||
name: "Postgres admin", username: "postgres",
|
||||
name: text("Postgres admin", "Postgres 管理员"), username: "postgres",
|
||||
credentialRef: "termind.password.postgres", keyID: workKey.id,
|
||||
notes: "Key to get in, password for sudo",
|
||||
notes: text("Key to get in, password for sudo", "使用密钥登录,密码用于 sudo"),
|
||||
lastUsedAt: now.addingTimeInterval(-7_200), colorTag: .orange
|
||||
)
|
||||
let consoleRoot = Identity(
|
||||
name: "Console root", username: "root",
|
||||
name: text("Console root", "控制台 root"), username: "root",
|
||||
credentialRef: "termind.password.console-root",
|
||||
notes: "Out-of-band only", colorTag: .red
|
||||
notes: text("Out-of-band only", "仅限带外管理"), colorTag: .red
|
||||
)
|
||||
let unfinished = Identity(
|
||||
name: "New admin", username: "admin",
|
||||
notes: "No secret chosen yet"
|
||||
name: text("New admin", "新管理员"), username: "admin",
|
||||
notes: text("No secret chosen yet", "尚未选择凭据")
|
||||
)
|
||||
|
||||
// Half the fleet is left without an identity on purpose, so the "not set
|
||||
@@ -59,65 +72,82 @@ extension HostLibraryModel {
|
||||
Host(
|
||||
name: "web-01", host: "10.0.1.5", identityID: deploy.id,
|
||||
groupID: production.id, tags: ["nginx", "prod"], colorTag: .red,
|
||||
detectedDistribution: distribution("ubuntu"),
|
||||
sortIndex: 0
|
||||
),
|
||||
Host(
|
||||
name: "web-02", host: "10.0.1.6", identityID: deploy.id,
|
||||
groupID: production.id, tags: ["nginx", "prod"], colorTag: .red,
|
||||
detectedDistribution: distribution("debian"),
|
||||
sortIndex: 1
|
||||
),
|
||||
Host(
|
||||
name: "db-primary", host: "10.0.2.10", identityID: postgres.id,
|
||||
groupID: production.id, tags: ["postgres"], colorTag: .purple,
|
||||
detectedDistribution: distribution("rocky"),
|
||||
sortIndex: 2
|
||||
),
|
||||
Host(
|
||||
name: "cdn-edge-tokyo", host: "203.0.113.7", port: 2222, identityID: consoleRoot.id,
|
||||
groupID: edge.id, tags: ["varnish", "apac"], colorTag: .orange,
|
||||
detectedDistribution: distribution("alpine"),
|
||||
sortIndex: 0
|
||||
),
|
||||
Host(
|
||||
name: "staging-app", host: "staging.internal",
|
||||
groupID: staging.id, tags: ["staging"], colorTag: .yellow,
|
||||
detectedDistribution: distribution("fedora"),
|
||||
sortIndex: 0
|
||||
),
|
||||
Host(
|
||||
name: "nas", host: "192.168.1.20",
|
||||
groupID: homelab.id, tags: ["zfs"], colorTag: .teal, sortIndex: 0
|
||||
groupID: homelab.id, tags: ["zfs"], colorTag: .teal,
|
||||
detectedDistribution: distribution("nixos"), sortIndex: 0
|
||||
),
|
||||
Host(
|
||||
name: "pi-hole", host: "192.168.1.21",
|
||||
groupID: homelab.id, tags: ["dns"], colorTag: .green, sortIndex: 1
|
||||
groupID: homelab.id, tags: ["dns"], colorTag: .green,
|
||||
detectedDistribution: distribution("raspberrypios"), sortIndex: 1
|
||||
),
|
||||
Host(
|
||||
name: "scratch-box", host: "172.16.0.9",
|
||||
detectedDistribution: distribution("opensuse-tumbleweed"),
|
||||
sortIndex: 0
|
||||
),
|
||||
]
|
||||
|
||||
let diagnostics = SnippetPackage(name: "Diagnostics", sortIndex: 0)
|
||||
let diagnostics = SnippetPackage(
|
||||
name: text("Diagnostics", "诊断"), sortIndex: 0
|
||||
)
|
||||
let webOperations = SnippetPackage(
|
||||
name: "Web Operations", parentID: diagnostics.id, sortIndex: 0
|
||||
name: text("Web Operations", "Web 运维"),
|
||||
parentID: diagnostics.id, sortIndex: 0
|
||||
)
|
||||
let snippets = [
|
||||
Snippet(
|
||||
title: "Disk usage by directory", body: "du -sh /* | sort -h",
|
||||
title: text("Disk usage by directory", "按目录查看磁盘占用"),
|
||||
body: "du -sh /* | sort -h",
|
||||
packageID: diagnostics.id, targetHostIDs: [hosts[0].id],
|
||||
sortIndex: 0
|
||||
),
|
||||
Snippet(
|
||||
title: "Top memory consumers", body: "ps aux --sort=-%mem | head -20",
|
||||
title: text("Top memory consumers", "内存占用最高的进程"),
|
||||
body: "ps aux --sort=-%mem | head -20",
|
||||
packageID: diagnostics.id,
|
||||
targetHostIDs: [hosts[0].id, hosts[1].id],
|
||||
sortIndex: 1
|
||||
),
|
||||
Snippet(
|
||||
title: "Tail nginx errors", body: "tail -f /var/log/nginx/error.log",
|
||||
title: text("Tail nginx errors", "持续查看 Nginx 错误"),
|
||||
body: "tail -f /var/log/nginx/error.log",
|
||||
packageID: webOperations.id,
|
||||
targetHostIDs: [hosts[0].id, hosts[1].id],
|
||||
sortIndex: 0
|
||||
),
|
||||
Snippet(title: "Listening ports", body: "ss -tulpn", sortIndex: 3),
|
||||
Snippet(
|
||||
title: text("Listening ports", "监听中的端口"),
|
||||
body: "ss -tulpn", sortIndex: 3
|
||||
),
|
||||
]
|
||||
|
||||
let knownHosts = [
|
||||
@@ -151,11 +181,12 @@ extension HostLibraryModel {
|
||||
let portForwardings = [
|
||||
PortForwardingRule(
|
||||
hostID: db.id, kind: .local, bindPort: 5432,
|
||||
targetHost: "localhost", targetPort: 5432, label: "Postgres"
|
||||
targetHost: "localhost", targetPort: 5432,
|
||||
label: text("Postgres", "Postgres 数据库")
|
||||
),
|
||||
PortForwardingRule(
|
||||
hostID: web01.id, kind: .dynamic, bindPort: 1080,
|
||||
label: "SOCKS proxy"
|
||||
label: text("SOCKS proxy", "SOCKS 代理")
|
||||
),
|
||||
]
|
||||
|
||||
@@ -177,7 +208,8 @@ extension HostLibraryModel {
|
||||
SFTPTransfer(
|
||||
hostID: web01.id, direction: .upload,
|
||||
localPath: "~/Config/nginx.conf", remotePath: "/etc/nginx/nginx.conf",
|
||||
byteCount: 8_192, state: .failed(String(localized: "Permission denied")),
|
||||
byteCount: 8_192,
|
||||
state: .failed(text("Permission denied", "权限被拒绝")),
|
||||
queuedAt: now.addingTimeInterval(-3_600)
|
||||
),
|
||||
SFTPTransfer(
|
||||
@@ -205,7 +237,9 @@ extension HostLibraryModel {
|
||||
store.setStatus(.connected, for: web01.id)
|
||||
store.setStatus(.connecting, for: db.id)
|
||||
if let staging = hosts.first(where: { $0.name == "staging-app" }) {
|
||||
store.setStatus(.failed("Connection refused"), for: staging.id)
|
||||
store.setStatus(
|
||||
.failed(text("Connection refused", "连接被拒绝")), for: staging.id
|
||||
)
|
||||
}
|
||||
|
||||
return store
|
||||
|
||||
@@ -2427,23 +2427,31 @@ extension WeakAgentUserInteraction: AgentUserInteractionHandling {}
|
||||
// MARK: - Demo data
|
||||
|
||||
extension ConversationModel {
|
||||
static func demo() -> ConversationModel {
|
||||
static func demo(language: AppLanguage = .english) -> ConversationModel {
|
||||
func text(_ en: String, _ zh: String) -> String {
|
||||
language == .simplifiedChineseChina ? zh : en
|
||||
}
|
||||
let now = Date(timeIntervalSinceReferenceDate: 809_020_800)
|
||||
let user = ChatMessage(
|
||||
role: .user,
|
||||
text: "Find the cause of the 502 responses. Inspect only—do not change configuration.",
|
||||
text: text(
|
||||
"Find the cause of the 502 responses. Inspect only—do not change configuration.",
|
||||
"查明 502 响应的原因。仅检查,不要更改配置。"
|
||||
),
|
||||
createdAt: now.addingTimeInterval(-18)
|
||||
)
|
||||
let answer = ChatMessage(
|
||||
role: .assistant,
|
||||
text:
|
||||
text: text(
|
||||
"The upstream service is not listening on `127.0.0.1:8080`. Nginx itself is healthy, and no configuration was changed. Start the upstream service, then verify `/health` before considering a reload.",
|
||||
"上游服务没有监听 `127.0.0.1:8080`。Nginx 本身运行正常,且没有更改任何配置。请先启动上游服务并验证 `/health`,再考虑重新加载。"
|
||||
),
|
||||
createdAt: now
|
||||
)
|
||||
let runID = UUID()
|
||||
let descriptor = AgentToolDescriptor(
|
||||
name: "ssh_exec",
|
||||
summary: "Run a command on a remote host.",
|
||||
summary: text("Run a command on a remote host.", "在远程主机上运行命令。"),
|
||||
inputSchema: .object([:]), target: .host, safety: .locallyReadOnly,
|
||||
concurrency: .parallel,
|
||||
presentation: AgentToolDescriptor.Presentation(
|
||||
@@ -2471,7 +2479,7 @@ extension ConversationModel {
|
||||
)
|
||||
}
|
||||
let conversation = ConversationModel(messages: [user, answer])
|
||||
conversation.customTitle = "Diagnose 502 responses"
|
||||
conversation.customTitle = text("Diagnose 502 responses", "诊断 502 响应")
|
||||
conversation.messages = [
|
||||
user, answer,
|
||||
]
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
#if DEBUG
|
||||
import Testing
|
||||
|
||||
@testable import Termind
|
||||
|
||||
@MainActor
|
||||
struct ScreenshotDemoTests {
|
||||
@Test
|
||||
func hostsIsTheFirstDemoScene() {
|
||||
#expect(ScreenshotDemoConfiguration.Scene.allCases.first == .hosts)
|
||||
}
|
||||
|
||||
@Test
|
||||
func parsesEverySceneAndLanguageCombination() throws {
|
||||
let languages: [(argument: String, language: AppLanguage)] = [
|
||||
("en", .english),
|
||||
("zh", .simplifiedChineseChina),
|
||||
]
|
||||
|
||||
for scene in ScreenshotDemoConfiguration.Scene.allCases {
|
||||
for language in languages {
|
||||
let parsed = try ScreenshotDemoConfiguration.parse(arguments: [
|
||||
"Termind", "--termind-demo=\(scene.rawValue):\(language.argument)",
|
||||
])
|
||||
let configuration = try #require(parsed)
|
||||
#expect(configuration.scene == scene)
|
||||
#expect(configuration.language == language.language)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func noDemoArgumentUsesOrdinaryLaunch() throws {
|
||||
#expect(
|
||||
try ScreenshotDemoConfiguration.parse(arguments: ["Termind"]) == nil
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
func rejectsIncompleteAndUnknownArguments() {
|
||||
#expect(throws: ScreenshotDemoConfiguration.ParseError.invalidFormat(
|
||||
"--termind-demo"
|
||||
)) {
|
||||
try ScreenshotDemoConfiguration.parse(arguments: [
|
||||
"Termind", "--termind-demo",
|
||||
])
|
||||
}
|
||||
#expect(throws: ScreenshotDemoConfiguration.ParseError.invalidFormat(
|
||||
"--termind-demo=hosts"
|
||||
)) {
|
||||
try ScreenshotDemoConfiguration.parse(arguments: [
|
||||
"Termind", "--termind-demo=hosts",
|
||||
])
|
||||
}
|
||||
#expect(throws: ScreenshotDemoConfiguration.ParseError.unknownScene("vault")) {
|
||||
try ScreenshotDemoConfiguration.parse(arguments: [
|
||||
"Termind", "--termind-demo=vault:en",
|
||||
])
|
||||
}
|
||||
#expect(throws: ScreenshotDemoConfiguration.ParseError.unknownLanguage("fr")) {
|
||||
try ScreenshotDemoConfiguration.parse(arguments: [
|
||||
"Termind", "--termind-demo=hosts:fr",
|
||||
])
|
||||
}
|
||||
#expect(throws: ScreenshotDemoConfiguration.ParseError.duplicateArguments) {
|
||||
try ScreenshotDemoConfiguration.parse(arguments: [
|
||||
"Termind", "--termind-demo=hosts:en", "--termind-demo=hosts:zh",
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func hostsSceneOpensHostsWithNavigationVisible() {
|
||||
let configuration = ScreenshotDemoConfiguration(
|
||||
scene: .hosts, language: .english
|
||||
)
|
||||
let window = configuration.makeWindow(hosts: .demo())
|
||||
|
||||
#expect(window.selectedDestination == .vault)
|
||||
#expect(window.vaultDestination == .hosts)
|
||||
#expect(window.showsSidebar)
|
||||
#expect(window.sidebarMode == .navigation)
|
||||
}
|
||||
|
||||
@Test
|
||||
func workspaceScenesBuildExpectedWindowState() {
|
||||
let hosts = HostLibraryModel.demo()
|
||||
let workspace = ScreenshotDemoConfiguration(
|
||||
scene: .workspace, language: .english
|
||||
).makeWindow(hosts: hosts)
|
||||
let command = ScreenshotDemoConfiguration(
|
||||
scene: .command, language: .english
|
||||
).makeWindow(hosts: hosts)
|
||||
let agent = ScreenshotDemoConfiguration(
|
||||
scene: .agent, language: .english
|
||||
).makeWindow(hosts: hosts)
|
||||
|
||||
#expect(workspace.selectedWorkspace != nil)
|
||||
#expect(!workspace.showsSidebar)
|
||||
#expect(command.selectedWorkspace != nil)
|
||||
#expect(command.showsSidebar)
|
||||
#expect(command.sidebarMode == .navigation)
|
||||
#expect(agent.selectedWorkspace != nil)
|
||||
#expect(agent.showsSidebar)
|
||||
#expect(agent.sidebarMode == .assistant)
|
||||
}
|
||||
|
||||
@Test
|
||||
func generatedDemoContentFollowsExplicitLanguage() throws {
|
||||
let englishHosts = HostLibraryModel.demo(language: .english)
|
||||
let chineseHosts = HostLibraryModel.demo(language: .simplifiedChineseChina)
|
||||
#expect(englishHosts.rootGroups.first?.name == "Production")
|
||||
#expect(chineseHosts.rootGroups.first?.name == "生产环境")
|
||||
|
||||
let englishConversation = ConversationModel.demo(language: .english)
|
||||
let chineseConversation = ConversationModel.demo(
|
||||
language: .simplifiedChineseChina
|
||||
)
|
||||
#expect(englishConversation.customTitle == "Diagnose 502 responses")
|
||||
#expect(chineseConversation.customTitle == "诊断 502 响应")
|
||||
#expect(try #require(englishConversation.messages.first).text.hasPrefix("Find"))
|
||||
#expect(try #require(chineseConversation.messages.first).text.hasPrefix("查明"))
|
||||
}
|
||||
|
||||
@Test
|
||||
func demoHostsUseEightDistinctBundledDistributionIcons() throws {
|
||||
let hosts = HostLibraryModel.demo().hosts
|
||||
let distributions = try hosts.map {
|
||||
try #require($0.detectedDistribution)
|
||||
}
|
||||
#expect(Set(distributions.map(\.id)).count == 8)
|
||||
for distribution in distributions {
|
||||
#expect(LinuxDistributionIconCatalog.entry(for: distribution) != nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
Reference in new issue
Block a user