feat: admin service logger page

This commit is contained in:
Zhang Minghan 2024-01-14 12:11:59 +08:00
parent 1b1dcdd0c4
commit 84485eae16
28 changed files with 579 additions and 30 deletions

View File

@ -80,7 +80,6 @@ func (c *ChatInstance) ProcessLine(buf, data string) (string, error) {
return "", nil
}
fmt.Println(item)
if form := processChatResponse(item); form == nil {
// recursive call
if len(buf) > 0 {

View File

@ -1,6 +1,7 @@
package midjourney
import (
"chat/globals"
"chat/utils"
"fmt"
"github.com/gin-gonic/gin"
@ -31,7 +32,7 @@ func InWhiteList(ip string) bool {
func NotifyAPI(c *gin.Context) {
if !InWhiteList(c.ClientIP()) {
fmt.Println(fmt.Sprintf("[midjourney] notify api: banned request from %s", c.ClientIP()))
globals.Info(fmt.Sprintf("[midjourney] notify api: banned request from %s", c.ClientIP()))
c.AbortWithStatus(http.StatusForbidden)
return
}
@ -41,7 +42,7 @@ func NotifyAPI(c *gin.Context) {
c.AbortWithStatus(http.StatusBadRequest)
return
}
// fmt.Println(fmt.Sprintf("[midjourney] notify api: get notify: %s (from: %s)", utils.Marshal(form), c.ClientIP()))
globals.Debug(fmt.Sprintf("[midjourney] notify api: get notify: %s (from: %s)", utils.Marshal(form), c.ClientIP()))
if !utils.Contains(form.Status, []string{InProgress, Success, Failure}) {
// ignore

View File

@ -215,3 +215,38 @@ func UpdateRootPasswordAPI(c *gin.Context) {
"status": true,
})
}
func ListLoggerAPI(c *gin.Context) {
c.JSON(http.StatusOK, ListLogs())
}
func DownloadLoggerAPI(c *gin.Context) {
path := c.Query("path")
getBlobFile(c, path)
}
func DeleteLoggerAPI(c *gin.Context) {
path := c.Query("path")
if err := deleteLogFile(path); err != nil {
c.JSON(http.StatusOK, gin.H{
"status": false,
"error": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"status": true,
})
}
func ConsoleLoggerAPI(c *gin.Context) {
n := utils.ParseInt(c.Query("n"))
content := getLatestLogs(n)
c.JSON(http.StatusOK, gin.H{
"status": true,
"content": content,
})
}

49
admin/logger.go Normal file
View File

@ -0,0 +1,49 @@
package admin
import (
"chat/globals"
"chat/utils"
"fmt"
"github.com/gin-gonic/gin"
"strings"
)
type LogFile struct {
Path string `json:"path"`
Size int64 `json:"size"`
}
func ListLogs() []LogFile {
return utils.Each(utils.Walk("logs"), func(path string) LogFile {
return LogFile{
Path: strings.TrimLeft(path, "logs/"),
Size: utils.GetFileSize(path),
}
})
}
func getLogPath(path string) string {
return fmt.Sprintf("logs/%s", path)
}
func getBlobFile(c *gin.Context, path string) {
c.File(getLogPath(path))
}
func deleteLogFile(path string) error {
return utils.DeleteFile(getLogPath(path))
}
func getLatestLogs(n int) string {
if n <= 0 {
n = 100
}
content, err := utils.ReadFileLatestLines(getLogPath(globals.DefaultLoggerFile), n)
if err != nil {
return fmt.Sprintf("read error: %s", err.Error())
}
return content
}

View File

@ -1,6 +1,7 @@
package admin
import (
"chat/globals"
"fmt"
"github.com/spf13/viper"
)
@ -24,7 +25,7 @@ type Market struct {
func NewMarket() *Market {
var models MarketModelList
if err := viper.UnmarshalKey("market", &models); err != nil {
fmt.Println(fmt.Sprintf("[market] read config error: %s, use default config", err.Error()))
globals.Warn(fmt.Sprintf("[market] read config error: %s, use default config", err.Error()))
models = MarketModelList{}
}

View File

@ -26,4 +26,9 @@ func Register(app *gin.RouterGroup) {
app.POST("/admin/user/root", UpdateRootPasswordAPI)
app.POST("/admin/market/update", UpdateMarketAPI)
app.GET("/admin/logger/list", ListLoggerAPI)
app.GET("/admin/logger/download", DownloadLoggerAPI)
app.GET("/admin/logger/console", ConsoleLoggerAPI)
app.POST("/admin/logger/delete", DeleteLoggerAPI)
}

View File

@ -8,7 +8,7 @@
},
"package": {
"productName": "chatnio",
"version": "3.8.5"
"version": "3.8.6"
},
"tauri": {
"allowlist": {

View File

@ -0,0 +1,55 @@
import axios from "axios";
import { CommonResponse } from "@/admin/utils.ts";
import { getErrorMessage } from "@/utils/base.ts";
export type Logger = {
path: string;
size: number;
};
export async function listLoggers(): Promise<Logger[]> {
try {
const response = await axios.get("/admin/logger/list");
return response.data as Logger[];
} catch (e) {
console.warn(e);
return [];
}
}
export async function getLoggerConsole(n?: number): Promise<string> {
try {
const response = await axios.get(`/admin/logger/console?n=${n ?? 100}`);
return response.data.content as string;
} catch (e) {
console.warn(e);
return `failed to get info from server: ${getErrorMessage(e)}`;
}
}
export async function downloadLogger(path: string): Promise<void> {
try {
const response = await axios.get("/admin/logger/download", {
responseType: "blob",
params: { path },
});
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement("a");
link.href = url;
link.setAttribute("download", path);
document.body.appendChild(link);
link.click();
} catch (e) {
console.warn(e);
}
}
export async function deleteLogger(path: string): Promise<CommonResponse> {
try {
const response = await axios.post(`/admin/logger/delete?path=${path}`);
return response.data as CommonResponse;
} catch (e) {
console.warn(e);
return { status: false, error: getErrorMessage(e) };
}
}

View File

@ -7,6 +7,8 @@
@import "charge";
@import "system";
@import "subscription";
@import "logger";
.admin-page {
position: relative;
@ -33,6 +35,7 @@
.channel,
.charge,
.system,
.logger,
.admin-subscription
{
padding: 0 !important;

View File

@ -0,0 +1,127 @@
.logger {
width: 100%;
height: max-content;
padding: 2rem;
display: flex;
flex-direction: column;
.logger-card {
width: 100%;
height: 100%;
min-height: 20vh;
}
}
.logger-container {
.paragraph-header {
margin-bottom: 0.5rem;
}
}
.logger-toolbar {
display: flex;
flex-direction: row;
align-items: center;
input {
max-width: 4.5rem;
text-align: center;
}
button {
flex-shrink: 0;
}
& > * {
margin-right: 0.5rem !important;
white-space: nowrap;
&:last-child {
margin-right: 0;
}
}
}
.logger-console {
position: relative;
border-radius: var(--radius);
background-color: hsl(var(--background-dark));
color: hsl(var(--text-light));
font-size: 14px;
width: 100%;
height: max-content;
overflow: hidden;
pre {
width: 100%;
height: max-content;
min-height: 20vh;
max-height: 60vh;
overflow-x: hidden;
overflow-y: auto;
touch-action: pan-y;
padding: 0.5rem;
}
.console-icon {
position: absolute;
top: 0.75rem;
right: 0.75rem;
user-select: none;
}
}
.logger-list {
display: flex;
flex-direction: column;
width: 100%;
height: max-content;
& > * {
margin-bottom: 0.5rem;
&:last-child {
margin-bottom: 0;
}
}
}
.logger-item {
display: flex;
flex-direction: row;
padding: 0.75rem 1rem;
flex-wrap: wrap;
border-radius: var(--radius);
border: 1px solid hsl(var(--border));
transition: all 0.2s ease-in-out;
align-items: center;
&:hover {
border-color: hsl(var(--border-hover));
}
& > * {
margin-right: 1rem;
flex-shrink: 0;
white-space: nowrap;
&:last-child {
margin-right: 0;
}
}
.logger-item-title {
font-size: 16px;
color: hsl(var(--text));
}
.logger-item-size {
font-size: 14px;
color: hsl(var(--text-secondary));
}
.logger-item-action {
cursor: pointer;
}
}

View File

@ -5,6 +5,8 @@
@layer base {
:root {
--background: 0 0% 100%;
--background-light: 0 0% 100%;
--background-dark: 0 0% 0%;
--background-hover: 5 12% 100%;
--background-container: 0, 0%, 97%, 0.8;
--foreground: 240 10% 3.9%;
@ -42,6 +44,7 @@
--ring: 240 5% 64.9%;
--text: 0 0% 0%;
--text-light: 0 0% 100%;
--text-dark: 0 0% 100%;
--text-secondary: 0 0% 35%;
--text-secondary-dark: 0 0% 80%;

View File

@ -7,6 +7,7 @@ import Markdown from "@/components/Markdown.tsx";
export type ParagraphProps = {
title?: string;
children: React.ReactNode;
className?: string;
configParagraph?: boolean;
isCollapsed?: boolean;
onCollapse?: () => void;
@ -16,6 +17,7 @@ export type ParagraphProps = {
function Paragraph({
title,
children,
className,
configParagraph,
isCollapsed,
onCollapse,
@ -32,6 +34,7 @@ function Paragraph({
configParagraph && `config-paragraph`,
isCollapsed && `collapsable`,
collapsed && `collapsed`,
className,
)}
>
<div

View File

@ -5,6 +5,7 @@ import {
BookCopy,
CalendarRange,
CloudCog,
FileClock,
Gauge,
GitFork,
Radio,
@ -78,6 +79,11 @@ function MenuBar() {
icon={<Settings />}
path={"/system"}
/>
<MenuItem
title={t("admin.logger.title")}
icon={<FileClock />}
path={"/logger"}
/>
</div>
);
}

View File

@ -14,7 +14,7 @@ import React from "react";
import { syncSiteInfo } from "@/admin/api/info.ts";
import { getOfflineModels, loadPreferenceModels } from "@/utils/storage.ts";
export const version = "3.8.5";
export const version = "3.8.6";
export const dev: boolean = getDev();
export const deploy: boolean = true;
export let rest_api: string = getRestApi(deploy);

View File

@ -518,6 +518,11 @@
"searchEndpoint": "搜索接入点",
"searchQuery": "最大搜索结果数",
"searchTip": "DuckDuckGo 搜索接入点,如不填写自动使用 WebPilot 和 New Bing 逆向进行搜索功能(速度较慢)。\nDuckDuckGo API 项目搭建:[duckduckgo-api](https://github.com/binjie09/duckduckgo-api)。"
},
"logger": {
"title": "服务日志",
"console": "控制台",
"consoleLength": "日志条数"
}
},
"mask": {

View File

@ -478,7 +478,12 @@
"update": "Update"
},
"model-chart-tip": "Token usage",
"subscription": "Subscription Management"
"subscription": "Subscription Management",
"logger": {
"title": "service log",
"console": "Console",
"consoleLength": "Number of log entries"
}
},
"mask": {
"title": "Mask Settings",

View File

@ -478,7 +478,12 @@
"update": "更新"
},
"model-chart-tip": "トークンの使用状況",
"subscription": "サブスクリプション管理"
"subscription": "サブスクリプション管理",
"logger": {
"title": "サービスログ",
"console": "コンソール",
"consoleLength": "ログエントリの数"
}
},
"mask": {
"title": "プリセット設定",

View File

@ -478,7 +478,12 @@
"update": "Обновить"
},
"model-chart-tip": "Использование токенов",
"subscription": "Управление подписками"
"subscription": "Управление подписками",
"logger": {
"title": "Журнал обслуживания",
"console": "Консоль",
"consoleLength": "Количество записей в журнале"
}
},
"mask": {
"title": "Настройки маски",

View File

@ -30,6 +30,7 @@ const Broadcast = lazyFactor(() => import("@/routes/admin/Broadcast.tsx"));
const Subscription = lazyFactor(
() => import("@/routes/admin/Subscription.tsx"),
);
const Logger = lazyFactor(() => import("@/routes/admin/Logger.tsx"));
const router = createBrowserRouter(
[
@ -188,9 +189,23 @@ const router = createBrowserRouter(
</Suspense>
),
},
{
id: "admin-logger",
path: "logger",
element: (
<Suspense>
<Logger />
</Suspense>
),
},
],
ErrorBoundary: NotFound,
},
{
id: "not-found",
path: "*",
element: <NotFound />,
},
].filter(Boolean),
);

View File

@ -0,0 +1,135 @@
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card.tsx";
import { useTranslation } from "react-i18next";
import { useMemo, useState } from "react";
import { useEffectAsync } from "@/utils/hook.ts";
import {
Logger,
listLoggers,
downloadLogger,
deleteLogger,
getLoggerConsole,
} from "@/admin/api/logger.ts";
import { getSizeUnit } from "@/utils/base.ts";
import { Download, RotateCcw, Terminal, Trash } from "lucide-react";
import { toastState } from "@/admin/utils.ts";
import { useToast } from "@/components/ui/use-toast.ts";
import Paragraph from "@/components/Paragraph.tsx";
import { Label } from "@/components/ui/label.tsx";
import { NumberInput } from "@/components/ui/number-input.tsx";
import { Button } from "@/components/ui/button.tsx";
type LoggerItemProps = Logger & {
onUpdate: () => void;
};
function LoggerItem({ path, size, onUpdate }: LoggerItemProps) {
const { t } = useTranslation();
const { toast } = useToast();
const loggerSize = useMemo(() => getSizeUnit(size), [size]);
return (
<div className={`logger-item`}>
<div className={`logger-item-title`}>{path}</div>
<div className={`grow`} />
<div className={`logger-item-size`}>{loggerSize}</div>
<div
className={`logger-item-action`}
onClick={async () => downloadLogger(path)}
>
<Download className={`w-3 h-3`} />
</div>
<div className={`logger-item-action`}>
<Trash
className={`w-3 h-3 text-red-600`}
onClick={async () => {
const resp = await deleteLogger(path);
if (resp) onUpdate();
toastState(toast, t, resp, true);
}}
/>
</div>
</div>
);
}
function LoggerList() {
const [data, setData] = useState<Logger[]>([]);
const sync = async () => setData(await listLoggers());
useEffectAsync(async () => {
await sync();
}, []);
return (
<div className={`logger-list`}>
{data.map((logger, i) => (
<LoggerItem {...logger} key={i} onUpdate={sync} />
))}
</div>
);
}
function LoggerConsole() {
const { t } = useTranslation();
const [data, setData] = useState<string>("");
const [loading, setLoading] = useState<boolean>(false);
const [length, setLength] = useState<number>(100);
const sync = async () => {
if (loading) return;
setLoading(true);
setData(await getLoggerConsole(length));
setLoading(false);
};
useEffectAsync(sync, []);
return (
<Paragraph
title={t("admin.logger.console")}
className={`logger-container mb-2`}
isCollapsed={true}
>
<div className={`logger-toolbar`}>
<Label>{t("admin.logger.consoleLength")}</Label>
<NumberInput
value={length}
onValueChange={setLength}
min={1}
max={1000}
/>
<div className={`grow`} />
<Button onClick={sync} variant={`outline`} size={`icon`}>
<RotateCcw className={`w-4 h-4`} />
</Button>
</div>
<div className={`logger-console`}>
<Terminal className={`w-4 h-4 console-icon`} />
<pre className={`thin-scrollbar`}>{data}</pre>
</div>
</Paragraph>
);
}
function Logger() {
const { t } = useTranslation();
return (
<div className={`logger`}>
<Card className={`logger-card`}>
<CardHeader className={`select-none`}>
<CardTitle>{t("admin.logger.title")}</CardTitle>
</CardHeader>
<CardContent>
<LoggerConsole />
<LoggerList />
</CardContent>
</Card>
</div>
);
}
export default Logger;

View File

@ -88,3 +88,10 @@ export function resetJsArray<T>(arr: T[], target: T[]): T[] {
arr.splice(0, arr.length, ...target);
return arr;
}
export function getSizeUnit(size: number): string {
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(2)} KB`;
if (size < 1024 * 1024 * 1024) return `${(size / 1024 / 1024).toFixed(2)} MB`;
return `${(size / 1024 / 1024 / 1024).toFixed(2)} GB`;
}

View File

@ -180,7 +180,6 @@ func (c *Channel) ProcessError(err error) error {
}
content := err.Error()
fmt.Println(content)
if strings.Contains(content, c.GetEndpoint()) {
// hide the endpoint
replacer := fmt.Sprintf("channel://%d", c.GetId())

View File

@ -1,11 +1,11 @@
package connection
import (
"chat/globals"
"context"
"fmt"
"github.com/go-redis/redis/v8"
"github.com/spf13/viper"
"log"
)
var Cache *redis.Client
@ -27,7 +27,7 @@ func ConnectRedis() *redis.Client {
})
if err := pingRedis(Cache); err != nil {
log.Println(
globals.Warn(
fmt.Sprintf(
"[connection] failed to connect to redis host: %s (message: %s), will retry in 5 seconds",
viper.GetString("redis.host"),
@ -35,12 +35,12 @@ func ConnectRedis() *redis.Client {
),
)
} else {
log.Println(fmt.Sprintf("[connection] connected to redis (host: %s)", viper.GetString("redis.host")))
globals.Debug(fmt.Sprintf("[connection] connected to redis (host: %s)", viper.GetString("redis.host")))
}
if viper.GetBool("debug") {
Cache.FlushAll(context.Background())
log.Println(fmt.Sprintf("[connection] flush redis cache (host: %s)", viper.GetString("redis.host")))
globals.Debug(fmt.Sprintf("[connection] flush redis cache (host: %s)", viper.GetString("redis.host")))
}
return Cache
}

View File

@ -1,12 +1,12 @@
package connection
import (
"chat/globals"
"chat/utils"
"database/sql"
"fmt"
_ "github.com/go-sql-driver/mysql"
"github.com/spf13/viper"
"log"
)
var DB *sql.DB
@ -30,7 +30,7 @@ func ConnectMySQL() *sql.DB {
viper.GetString("mysql.db"),
))
if err != nil || db.Ping() != nil {
log.Println(
globals.Warn(
fmt.Sprintf("[connection] failed to connect to mysql server: %s (message: %s), will retry in 5 seconds",
viper.GetString("mysql.host"),
utils.GetError(err), // err.Error() may contain nil pointer
@ -42,7 +42,7 @@ func ConnectMySQL() *sql.DB {
return ConnectMySQL()
} else {
log.Println(fmt.Sprintf("[connection] connected to mysql server (host: %s)", viper.GetString("mysql.host")))
globals.Debug(fmt.Sprintf("[connection] connected to mysql server (host: %s)", viper.GetString("mysql.host")))
}
db.SetMaxOpenConns(512)
@ -69,21 +69,21 @@ func InitRootUser(db *sql.DB) {
var count int
err := db.QueryRow("SELECT COUNT(*) FROM auth").Scan(&count)
if err != nil {
fmt.Println(err)
globals.Warn(fmt.Sprintf("[service] failed to query user count: %s", err.Error()))
return
}
if count == 0 {
fmt.Println("[service] no user found, creating root user (username: root, password: chatnio123456, email: root@example.com)")
globals.Debug("[service] no user found, creating root user (username: root, password: chatnio123456, email: root@example.com)")
_, err := db.Exec(`
INSERT INTO auth (username, password, email, is_admin, bind_id, token)
VALUES (?, ?, ?, ?, ?, ?)
`, "root", utils.Sha2Encrypt("chatnio123456"), "root@example.com", true, 0, "root")
if err != nil {
fmt.Println(err)
globals.Warn(fmt.Sprintf("[service] failed to create root user: %s", err.Error()))
}
} else {
fmt.Println(fmt.Sprintf("[service] %d user(s) found, skip creating root user", count))
globals.Debug(fmt.Sprintf("[service] %d user(s) found, skip creating root user", count))
}
}

View File

@ -4,9 +4,12 @@ import (
"fmt"
"github.com/natefinch/lumberjack"
"github.com/sirupsen/logrus"
"github.com/spf13/viper"
"strings"
)
const DefaultLoggerFile = "chatnio.log"
var Logger *logrus.Logger
type AppLogger struct {
@ -21,6 +24,10 @@ func (l *AppLogger) Format(entry *logrus.Entry) ([]byte, error) {
entry.Message,
)
if !viper.GetBool("log.ignore_console") {
fmt.Println(data)
}
return []byte(data), nil
}
@ -31,10 +38,10 @@ func init() {
})
Logger.SetOutput(&lumberjack.Logger{
Filename: "logs/chat.log",
Filename: fmt.Sprintf("logs/%s", DefaultLoggerFile),
MaxSize: 1,
MaxBackups: 500,
MaxAge: 1,
MaxAge: 21, // 3 weeks
})
Logger.SetLevel(logrus.DebugLevel)

View File

@ -6,7 +6,6 @@ import (
"chat/utils"
"database/sql"
"fmt"
"log"
)
func (c *Conversation) SaveConversation(db *sql.DB) bool {
@ -28,7 +27,7 @@ func (c *Conversation) SaveConversation(db *sql.DB) bool {
defer func(stmt *sql.Stmt) {
err := stmt.Close()
if err != nil {
log.Println(err)
globals.Warn(err)
}
}(stmt)

View File

@ -132,7 +132,6 @@ func transformContent(content interface{}) string {
func transform(m []Message) []globals.Message {
var messages []globals.Message
for _, v := range m {
fmt.Println(transformContent(v.Content))
messages = append(messages, globals.Message{
Role: v.Role,
Content: transformContent(v.Content),

View File

@ -1,6 +1,9 @@
package utils
import (
"bufio"
"chat/globals"
"errors"
"fmt"
"io"
"os"
@ -42,10 +45,15 @@ func WriteFile(path string, data string, folderSafe bool) bool {
if err != nil {
return false
}
defer file.Close()
defer func(file *os.File) {
err := file.Close()
if err != nil {
globals.Warn(fmt.Sprintf("[utils] close file error: %s (path: %s)", err.Error(), path))
}
}(file)
if _, err := file.WriteString(data); err != nil {
fmt.Println(err.Error())
globals.Warn(fmt.Sprintf("[utils] write file error: %s (path: %s, bytes len: %d)", err.Error(), path, len(data)))
return false
}
return true
@ -68,6 +76,44 @@ func Walk(path string) []string {
return files
}
func GetFileSize(path string) int64 {
file, err := os.Open(path)
if err != nil {
return 0
}
defer func(file *os.File) {
err := file.Close()
if err != nil {
globals.Warn(fmt.Sprintf("[utils] close file error: %s (path: %s)", err.Error(), path))
}
}(file)
stat, err := file.Stat()
if err != nil {
return 0
}
return stat.Size()
}
func GetFileCreated(path string) string {
file, err := os.Open(path)
if err != nil {
return ""
}
defer func(file *os.File) {
err := file.Close()
if err != nil {
globals.Warn(fmt.Sprintf("[utils] close file error: %s (path: %s)", err.Error(), path))
}
}(file)
stat, err := file.Stat()
if err != nil {
return ""
}
return stat.ModTime().String()
}
func IsFileExist(path string) bool {
_, err := os.Stat(path)
return err == nil || os.IsExist(err)
@ -81,7 +127,7 @@ func CopyFile(src string, dst string) error {
defer func(in *os.File) {
err := in.Close()
if err != nil {
fmt.Println(err)
globals.Warn(fmt.Sprintf("[utils] close file error: %s (path: %s)", err.Error(), src))
}
}(in)
@ -93,10 +139,45 @@ func CopyFile(src string, dst string) error {
defer func(out *os.File) {
err := out.Close()
if err != nil {
fmt.Println(err)
globals.Warn(fmt.Sprintf("[utils] close file error: %s (path: %s)", err.Error(), dst))
}
}(out)
_, err = io.Copy(out, in)
return err
}
func DeleteFile(path string) error {
return os.Remove(path)
}
func ReadFileLatestLines(path string, length int) (string, error) {
if length <= 0 {
return "", errors.New("length must be greater than 0")
}
file, err := os.Open(path)
if err != nil {
return "", err
}
defer func(file *os.File) {
err := file.Close()
if err != nil {
globals.Warn(fmt.Sprintf("[utils] close file error: %s (path: %s)", err.Error(), path))
}
}(file)
scanner := bufio.NewScanner(file)
scanner.Split(bufio.ScanLines)
var lines []string
for scanner.Scan() {
lines = append(lines, scanner.Text())
}
if len(lines) < length {
length = len(lines)
}
return strings.Join(lines[len(lines)-length:], "\n"), nil
}